From 0890afaacc2b0bce3cda4a7cefd7d76e49cb319c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Hohwiller?= Date: Tue, 8 Sep 2026 18:50:44 +0200 Subject: [PATCH 01/66] #1628: fixed CHANGELOG release 2026.09.001 was already closed --- CHANGELOG.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 480c6a6c..f74e69c8 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -10,6 +10,7 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/2178[#2178]: Make ReleaseCommandlet independent of specific build commandlet and fix `ide build` using npm instead of yarn * https://github.com/devonfw/IDEasy/issues/2142[#2142]: Move IDE-specific metadata (.idea, .vscode) out of workspace * https://github.com/devonfw/IDEasy/issues/989[#989]: Allow expressions in template variable definitions +* https://github.com/devonfw/IDEasy/issues/1628[#1628]: Support for OS-specific CVEs The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/50?closed=1[milestone 2026.09.002]. @@ -17,7 +18,6 @@ The full list of changes for this release can be found in https://github.com/dev Release with new features and bugfixes: -* https://github.com/devonfw/IDEasy/issues/1628[#1628]: Support for OS-specific CVEs * https://github.com/devonfw/IDEasy/issues/1525[#1525]: Document known issue and workaround for lombok plugin in Eclipse * https://github.com/devonfw/IDEasy/issues/1031[#1031]: Added OpenRewrite commandlet * https://github.com/devonfw/IDEasy/issues/2361[#2361]: Improve dotnet installation by setting DOTNET_ROOT From 09c20b869ca3f5d231f62d245656daf46cfd6602 Mon Sep 17 00:00:00 2001 From: quando632 Date: Fri, 11 Sep 2026 17:38:02 +0200 Subject: [PATCH 02/66] #2058 fix github authentication for vsccode (#2072) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Malte Brunnlieb Co-authored-by: Jörg Hohwiller --- .../devonfw/tools/ide/tool/vscode/Vscode.java | 19 ++++++++++-- .../tools/ide/variable/IdeVariables.java | 7 +++++ .../tools/ide/tool/vscode/VscodeTest.java | 31 +++++++++++++++++-- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/vscode/Vscode.java b/cli/src/main/java/com/devonfw/tools/ide/tool/vscode/Vscode.java index f1131384..3ce71ddd 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/vscode/Vscode.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/vscode/Vscode.java @@ -18,6 +18,7 @@ import com.devonfw.tools.ide.tool.ToolCommandlet; import com.devonfw.tools.ide.tool.ide.IdeToolCommandlet; import com.devonfw.tools.ide.tool.plugin.ToolPluginDescriptor; +import com.devonfw.tools.ide.variable.IdeVariables; /** * {@link ToolCommandlet} for vscode. @@ -80,15 +81,29 @@ public boolean installPlugin(ToolPluginDescriptor plugin, Step step, ProcessCont return false; } + /** + * @return the name of the VSCode profile used to isolate settings, extension state and authentication per IDEasy project and workspace. + */ + private String getProfileName() { + + return "ideasy-" + this.context.getProjectName() + "-" + this.context.getWorkspaceName(); + } + @Override protected void configureToolArgs(ProcessContext pc, ProcessMode processMode, List args) { if (this.context.getSystemInfo().isWsl()) { pc.withEnvVar("DONT_PROMPT_WSL_INSTALL", "1"); } - Path vsCodeConf = getIdeMetadataPath().resolve("config"); pc.addArg("--new-window"); - pc.addArg("--user-data-dir=" + vsCodeConf); + if (Boolean.TRUE.equals(IdeVariables.VSCODE_PROFILE_ENABLED.get(this.context))) { + // Use a named profile (not --user-data-dir) so VS Code keeps its IPC lock at the default location. + // This lets the OS-level vscode:// protocol handler (OAuth callbacks e.g. GitHub/Copilot) find the + // already-running IDEasy window. Each project and workspace gets its own profile for isolated auth and settings. + pc.addArg("--profile=" + getProfileName()); + } else { + pc.addArg("--user-data-dir=" + getIdeMetadataPath().resolve("config")); + } Path vsCodeExtensionFolder = this.context.getIdeHome().resolve("plugins/vscode"); pc.addArg("--extensions-dir=" + vsCodeExtensionFolder); pc.addArg(this.context.getWorkspacePath()); diff --git a/cli/src/main/java/com/devonfw/tools/ide/variable/IdeVariables.java b/cli/src/main/java/com/devonfw/tools/ide/variable/IdeVariables.java index 3fbb48c6..98fef82c 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/variable/IdeVariables.java +++ b/cli/src/main/java/com/devonfw/tools/ide/variable/IdeVariables.java @@ -126,6 +126,13 @@ public interface IdeVariables { /** {@link VariableDefinition} for support of overriding the default pycharm jvm options. */ VariableDefinitionString PYCHARM_VM_ARGS = new VariableDefinitionString("PYCHARM_VM_ARGS", null); + /** + * {@link VariableDefinition} to enable the isolation of VSCode via {@code --profile} instead of {@code --user-data-dir}. This is a feature toggle: while + * disabled (default) the legacy behaviour with {@code --user-data-dir} is used. See issue #2058 for details. + */ + VariableDefinitionBoolean VSCODE_PROFILE_ENABLED = new VariableDefinitionBoolean("VSCODE_PROFILE_ENABLED", null, + c -> Boolean.FALSE); + /** A {@link Collection} with all pre-defined {@link VariableDefinition}s. */ Collection> VARIABLES = List.of(PATH, HOME, WORKSPACE_PATH, IDE_HOME, IDE_ROOT, WORKSPACE, IDE_TOOLS, HTTP_VERSIONS, CREATE_START_SCRIPTS, diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/vscode/VscodeTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/vscode/VscodeTest.java index c6ecaada..e537ca07 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/vscode/VscodeTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/vscode/VscodeTest.java @@ -155,8 +155,9 @@ void testConfigureToolArgsDoesNotSetWslEnvVarOnNonWsl() { } /** - * Tests that {@link Vscode#configureToolArgs(ProcessContext, ProcessMode, List)} points {@code --user-data-dir} to the IDE metadata folder - * ({@code $IDE_HOME/.ide/vscode/«workspace»/config}) instead of a {@code .vscode} folder inside the workspace. + * Tests that by default (feature toggle {@code VSCODE_PROFILE_ENABLED} disabled) {@link Vscode#configureToolArgs(ProcessContext, ProcessMode, List)} points + * {@code --user-data-dir} to the IDE metadata folder ({@code $IDE_HOME/.ide/vscode/«workspace»/config}) instead of a {@code .vscode} folder inside the + * workspace. */ @Test void testConfigureToolArgsUsesIdeMetadataPathForUserData() { @@ -172,6 +173,31 @@ void testConfigureToolArgsUsesIdeMetadataPathForUserData() { Path expectedUserData = context.getIdeHome().resolve(IdeContext.FOLDER_DOT_IDE).resolve("vscode").resolve(context.getWorkspaceName()).resolve("config"); assertThat(pc.capturedArgs).contains("--user-data-dir=" + expectedUserData); assertThat(pc.capturedArgs).noneMatch(arg -> arg.contains(".vscode")); + assertThat(pc.capturedArgs).noneMatch(arg -> arg.startsWith("--profile=")); + } + + /** + * Tests that with the feature toggle {@code VSCODE_PROFILE_ENABLED} enabled VS Code is launched with a named {@code --profile} and without a custom + * {@code --user-data-dir}. + *

+ * Using {@code --profile} keeps auth and extension state isolated per project and workspace while keeping the VS Code IPC lock at the default user-data-dir + * location, so the OS-level {@code vscode://} protocol handler (e.g. GitHub/Copilot OAuth callbacks) can find and reuse the already-running instance. + */ + @Test + void testConfigureToolArgsUsesProfileIfFeatureToggleEnabled() { + + // arrange + IdeTestContext context = newContext(PROJECT_VSCODE); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + context.getVariables().getByType(EnvironmentVariablesType.CONF).set("VSCODE_PROFILE_ENABLED", "true"); + Vscode commandlet = new Vscode(context); + ArgCapturingProcessContext pc = new ArgCapturingProcessContext(context); + // act + commandlet.configureToolArgs(pc, ProcessMode.DEFAULT, List.of()); + // assert + assertThat(pc.capturedArgs).noneMatch(arg -> arg.startsWith("--user-data-dir=")); + // the profile has to contain the project name so that different projects with the same workspace name do not share a single profile (see #2058) + assertThat(pc.capturedArgs).contains("--profile=ideasy-" + context.getProjectName() + "-" + context.getWorkspaceName()); } /** @@ -297,6 +323,7 @@ public void installPluginsForTest(Collection plugins, Proc } } + /** * {@link ProcessContextTestImpl} subclass that captures calls to {@link #withEnvVar(String, String)} for test assertions. */ From d94cbf869b26b77282eeaf10e148ba4f571a75f1 Mon Sep 17 00:00:00 2001 From: devonfw-core Date: Mon, 14 Sep 2026 11:29:24 +0000 Subject: [PATCH 03/66] Update quality status documents --- .../functional-labels.svg | 50 ++++---- .../quality-status-images/issue-age.svg | 22 ++-- .../issue-assignment.svg | 10 +- .../quality-status-images/issue-types.svg | 18 +-- .../operating-systems.svg | 23 ++-- .../quality-status/quality-status-linux.adoc | 25 +++- .../quality-status/quality-status-macos.adoc | 36 ++++-- .../quality-status-windows.adoc | 30 +++-- .../quality-status/quality-status.adoc | 116 +++++++++++++----- 9 files changed, 223 insertions(+), 107 deletions(-) diff --git a/documentation/quality-status/quality-status-images/functional-labels.svg b/documentation/quality-status/quality-status-images/functional-labels.svg index 0f901686..caff3821 100644 --- a/documentation/quality-status/quality-status-images/functional-labels.svg +++ b/documentation/quality-status/quality-status-images/functional-labels.svg @@ -23,41 +23,41 @@ GUI -43 +46 install - -20 + +21 commandlet - -14 -settings + +18 +internal - -12 -internal + +17 +settings - -8 -software + +12 +AI - -8 -configuration + +11 +urls - -7 + +11 docker - -7 -urls + +8 +software - -7 -vscode + +8 +configuration - -7 + +7 diff --git a/documentation/quality-status/quality-status-images/issue-age.svg b/documentation/quality-status/quality-status-images/issue-age.svg index 1a1a1d43..92c35267 100644 --- a/documentation/quality-status/quality-status-images/issue-age.svg +++ b/documentation/quality-status/quality-status-images/issue-age.svg @@ -20,22 +20,22 @@ Issue age Issue age - + -0-10 days: 19 (11.1%) - +0-10 days: 35 (17.9%) + -11-30 days: 27 (15.8%) - +11-30 days: 33 (16.8%) + -31-60 days: 30 (17.5%) - +31-60 days: 36 (18.4%) + -61-90 days: 11 (6.4%) - +61-90 days: 12 (6.1%) + -90+ days: 84 (49.1%) +90+ days: 80 (40.8%) -171 +196 total diff --git a/documentation/quality-status/quality-status-images/issue-assignment.svg b/documentation/quality-status/quality-status-images/issue-assignment.svg index 7de3697a..49cf95df 100644 --- a/documentation/quality-status/quality-status-images/issue-assignment.svg +++ b/documentation/quality-status/quality-status-images/issue-assignment.svg @@ -20,13 +20,13 @@ Assigned vs unassigned issues Assigned vs unassigned issues - + -Assigned: 102 (59.6%) - +Assigned: 128 (65.3%) + -Unassigned: 69 (40.4%) +Unassigned: 68 (34.7%) -171 +196 total diff --git a/documentation/quality-status/quality-status-images/issue-types.svg b/documentation/quality-status/quality-status-images/issue-types.svg index 734a9287..61e9f2c8 100644 --- a/documentation/quality-status/quality-status-images/issue-types.svg +++ b/documentation/quality-status/quality-status-images/issue-types.svg @@ -20,19 +20,19 @@ Issue type distribution Issue type distribution - + -Bugs: 37 (21.6%) - +Bugs: 38 (19.4%) + -Tasks: 57 (33.3%) - +Tasks: 70 (35.7%) + -Features: 75 (43.9%) - +Features: 82 (41.8%) + -No Type: 2 (1.2%) +No Type: 6 (3.1%) -171 +196 total diff --git a/documentation/quality-status/quality-status-images/operating-systems.svg b/documentation/quality-status/quality-status-images/operating-systems.svg index 940f31d4..7c8760ee 100644 --- a/documentation/quality-status/quality-status-images/operating-systems.svg +++ b/documentation/quality-status/quality-status-images/operating-systems.svg @@ -27,17 +27,18 @@ Cross-platform Windows - - -151 + + + +174 Linux - - - -148 + + + +170 macOS - - - -155 + + + +178 diff --git a/documentation/quality-status/quality-status-linux.adoc b/documentation/quality-status/quality-status-linux.adoc index f0a8a877..a8403181 100644 --- a/documentation/quality-status/quality-status-linux.adoc +++ b/documentation/quality-status/quality-status-linux.adoc @@ -9,13 +9,13 @@ Automatically generated open issue overview for Linux. link:quality-status.adoc[Back to overview] -*Total Issues:* 148 +*Total Issues:* 170 * OS-specific: 6 -* Multi-OS: 1 +* Multi-OS: 3 -* Cross-platform: 141 +* Cross-platform: 161 == Linux Specific Issues @@ -50,11 +50,28 @@ link:quality-status.adoc[Back to overview] == Multi-OS Issues -=== Linux + macOS (1) +=== Linux + Windows + macOS (1) [%header, cols="^1,7"] |=== | Issue | Summary +2+^| *0-10 days* + +| link:https://github.com/devonfw/IDEasy/issues/2464[#2464] +| Add unit tests for `OperatingSystem` + +|=== + +=== Linux + macOS (2) + +[%header, cols="^1,7"] +|=== +| Issue | Summary +2+^| *0-10 days* + +| link:https://github.com/devonfw/IDEasy/issues/2467[#2467] +| Obsidian: fix installation on macOS/Linux and duplicate LINUX/X64 artifact in O… + 2+^| *90+ days* | link:https://github.com/devonfw/IDEasy/issues/802[#802] diff --git a/documentation/quality-status/quality-status-macos.adoc b/documentation/quality-status/quality-status-macos.adoc index ffb5b109..34f71d99 100644 --- a/documentation/quality-status/quality-status-macos.adoc +++ b/documentation/quality-status/quality-status-macos.adoc @@ -9,29 +9,34 @@ Automatically generated open issue overview for macOS. link:quality-status.adoc[Back to overview] -*Total Issues:* 155 +*Total Issues:* 178 -* OS-specific: 13 +* OS-specific: 14 -* Multi-OS: 1 +* Multi-OS: 3 -* Cross-platform: 141 +* Cross-platform: 161 == macOS Specific Issues [%header, cols="^1,7"] |=== | Issue | Summary +2+^| *0-10 days* + +| link:https://github.com/devonfw/IDEasy/issues/2468[#2468] +| Global tool commandlets ignore configured edition for name/version detection (W… + 2+^| *11-30 days* | link:https://github.com/devonfw/IDEasy/issues/2344[#2344] | Small cleanup: align macos pkg build script with windows msi build script +2+^| *31-60 days* + | link:https://github.com/devonfw/IDEasy/issues/2316[#2316] | Homebrew formula publish fails: brew audit no longer accepts a path -2+^| *31-60 days* - | link:https://github.com/devonfw/IDEasy/issues/2252[#2252] | Provide uninstall support for globally installed tools (macOS) @@ -71,11 +76,28 @@ link:quality-status.adoc[Back to overview] == Multi-OS Issues -=== Linux + macOS (1) +=== Linux + Windows + macOS (1) + +[%header, cols="^1,7"] +|=== +| Issue | Summary +2+^| *0-10 days* + +| link:https://github.com/devonfw/IDEasy/issues/2464[#2464] +| Add unit tests for `OperatingSystem` + +|=== + +=== Linux + macOS (2) [%header, cols="^1,7"] |=== | Issue | Summary +2+^| *0-10 days* + +| link:https://github.com/devonfw/IDEasy/issues/2467[#2467] +| Obsidian: fix installation on macOS/Linux and duplicate LINUX/X64 artifact in O… + 2+^| *90+ days* | link:https://github.com/devonfw/IDEasy/issues/802[#802] diff --git a/documentation/quality-status/quality-status-windows.adoc b/documentation/quality-status/quality-status-windows.adoc index cc1c40ab..90a51bbb 100644 --- a/documentation/quality-status/quality-status-windows.adoc +++ b/documentation/quality-status/quality-status-windows.adoc @@ -9,13 +9,13 @@ Automatically generated open issue overview for Windows. link:quality-status.adoc[Back to overview] -*Total Issues:* 151 +*Total Issues:* 174 -* OS-specific: 10 +* OS-specific: 12 -* Multi-OS: 0 +* Multi-OS: 1 -* Cross-platform: 141 +* Cross-platform: 161 == Windows Specific Issues @@ -24,14 +24,20 @@ link:quality-status.adoc[Back to overview] | Issue | Summary 2+^| *0-10 days* +| link:https://github.com/devonfw/IDEasy/issues/2442[#2442] +| Add pom.xml to windows-installer and align its build with the maven-driven macO… + +| link:https://github.com/devonfw/IDEasy/issues/2431[#2431] +| Improve uv support to make it usable for IDE_ROOT on non-default drive + +2+^| *11-30 days* + | link:https://github.com/devonfw/IDEasy/issues/2406[#2406] | Prepare IDEasy for Microsoft Store App Submission | link:https://github.com/devonfw/IDEasy/issues/2405[#2405] | Set Up Microsoft Store Developer Account for IDEasy -2+^| *11-30 days* - | link:https://github.com/devonfw/IDEasy/issues/2342[#2342] | Display ProgressBar progress in windows taskbar @@ -64,7 +70,17 @@ link:quality-status.adoc[Back to overview] == Multi-OS Issues -No multi-OS issues. +=== Linux + Windows + macOS (1) + +[%header, cols="^1,7"] +|=== +| Issue | Summary +2+^| *0-10 days* + +| link:https://github.com/devonfw/IDEasy/issues/2464[#2464] +| Add unit tests for `OperatingSystem` + +|=== == Cross-platform Issues diff --git a/documentation/quality-status/quality-status.adoc b/documentation/quality-status/quality-status.adoc index df1c6171..98ab4556 100644 --- a/documentation/quality-status/quality-status.adoc +++ b/documentation/quality-status/quality-status.adoc @@ -20,7 +20,7 @@ image::quality-status-images/issue-assignment.svg[Pie chart: assigned vs unassig image::quality-status-images/issue-types.svg[Pie chart: issue type distribution,width=100%] -_Generated: 2026-09-07 11:19 UTC_ +_Generated: 2026-09-14 11:29 UTC_ == Operating System Status Files @@ -29,7 +29,7 @@ Issues are assigned to operating systems based on their labels: `windows`, `linux`, or `macOS`. Issues without an operating system label are treated as cross-platform. -*A total of 141 cross-platform issues* are documented centrally in this document and are therefore not repeated in the operating system specific files. +*A total of 161 cross-platform issues* are documented centrally in this document and are therefore not repeated in the operating system specific files. The detailed tool status is split into one generated file per operating system. @@ -58,6 +58,84 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | Issue | Summary 2+^a| [[cross-platform-0-10-days]] *0-10 days* +| link:https://github.com/devonfw/IDEasy/issues/2470[#2470] +| Create a client service PoC for reading URL metadata + +| link:https://github.com/devonfw/IDEasy/issues/2466[#2466] +| ide uninstall should also remove broken link + +| link:https://github.com/devonfw/IDEasy/issues/2463[#2463] +| Add unit tests for `ToolCommandlet` + +| link:https://github.com/devonfw/IDEasy/issues/2462[#2462] +| Create UrlUpdater for codex + +| link:https://github.com/devonfw/IDEasy/issues/2461[#2461] +| Consider integration of codex + +| link:https://github.com/devonfw/IDEasy/issues/2460[#2460] +| Create UrlUpdater for gemini-cli + +| link:https://github.com/devonfw/IDEasy/issues/2459[#2459] +| Consider integration of gemini + +| link:https://github.com/devonfw/IDEasy/issues/2458[#2458] +| Create UrlUpdater for antigravity + +| link:https://github.com/devonfw/IDEasy/issues/2457[#2457] +| Consider integration of antigravity + +| link:https://github.com/devonfw/IDEasy/issues/2456[#2456] +| Add unit tests for `UrlSecurityFile` + +| link:https://github.com/devonfw/IDEasy/issues/2455[#2455] +| Add unit tests for `PropertiesMerger` + +| link:https://github.com/devonfw/IDEasy/issues/2454[#2454] +| Solve existing checkstyle violations: Module `url-updater` + +| link:https://github.com/devonfw/IDEasy/issues/2453[#2453] +| Solve existing checkstyle violations: Module `security` + +| link:https://github.com/devonfw/IDEasy/issues/2452[#2452] +| Solve existing checkstyle violations: Module `cli` + +| link:https://github.com/devonfw/IDEasy/issues/2451[#2451] +| Solve existing checkstyle violations + +| link:https://github.com/devonfw/IDEasy/issues/2450[#2450] +| Increase test coverage + +| link:https://github.com/devonfw/IDEasy/issues/2449[#2449] +| NPM install fails during ide create + +| link:https://github.com/devonfw/IDEasy/issues/2448[#2448] +| Add Native AI Integration Support for IDEasy + +| link:https://github.com/devonfw/IDEasy/issues/2440[#2440] +| Add a possibility to open the IdeLauncher Tab when it's closed + +| link:https://github.com/devonfw/IDEasy/issues/2439[#2439] +| Implement AlertFactory for the GUI + +| link:https://github.com/devonfw/IDEasy/issues/2436[#2436] +| Plugin step progress index broken + +| link:https://github.com/devonfw/IDEasy/issues/2433[#2433] +| Consider adding an EventBus to the new GUI + +| link:https://github.com/devonfw/IDEasy/issues/2428[#2428] +| Prevent workflows to run automatically on IDEasy-workflow-testing + +| link:https://github.com/devonfw/IDEasy/issues/2427[#2427] +| Improve update-cve workflow to detect and add OS specific CVEs to security.json… + +| link:https://github.com/devonfw/IDEasy/issues/2426[#2426] +| Document how to run GitHub Actions locally with act + +| link:https://github.com/devonfw/IDEasy/issues/2423[#2423] +| isRepositoryUpdateAvailable is not covered by any test that reaches the compari… + | link:https://github.com/devonfw/IDEasy/issues/2422[#2422] | Cleanup how to determine installed edition and version and potentially restore… @@ -65,16 +143,18 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | Improve BuildTool support further | link:https://github.com/devonfw/IDEasy/issues/2419[#2419] -| IDEasy AI Sandbox Integration using Docker Sandboxes +| Analysis of IDEasy AI Sandbox Integration using Docker Sandboxes | link:https://github.com/devonfw/IDEasy/issues/2417[#2417] | Analysis of OpenShell Nvidia for AI Agent +2+^a| [[cross-platform-11-30-days]] *11-30 days* + | link:https://github.com/devonfw/IDEasy/issues/2411[#2411] | Improve overview of projects which use IDEasy | link:https://github.com/devonfw/IDEasy/issues/2410[#2410] -| Do research for a Bug report commandlet +| Improve support for bug report | link:https://github.com/devonfw/IDEasy/issues/2409[#2409] | Cleanup of expression functions @@ -109,8 +189,6 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2393[#2393] | Open a project folder of my choice (e.g. an external Git repo) instead of alway… -2+^a| [[cross-platform-11-30-days]] *11-30 days* - | link:https://github.com/devonfw/IDEasy/issues/2385[#2385] | Cleanup of IdeLogEntry and IdeGuiListener @@ -156,6 +234,8 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2337[#2337] | Analysis of IDEasy instance in docker +2+^a| [[cross-platform-31-60-days]] *31-60 days* + | link:https://github.com/devonfw/IDEasy/issues/2336[#2336] | Reconsider treating idea.properties as a user-configurable workspace template @@ -171,17 +251,9 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2322[#2322] | Align used icons for the GUI -| link:https://github.com/devonfw/IDEasy/issues/2319[#2319] -| 'ide status' should report an unsigned license agreement, not error/hang on it - | link:https://github.com/devonfw/IDEasy/issues/2314[#2314] | Implement the handling of Steps in the GUI. -| link:https://github.com/devonfw/IDEasy/issues/2311[#2311] -| SoapUI not working in projects that do not have java installed - -2+^a| [[cross-platform-31-60-days]] *31-60 days* - | link:https://github.com/devonfw/IDEasy/issues/2283[#2283] | GUI blocks commandlets when license agreement is missing @@ -257,11 +329,11 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2182[#2182] | Basic structure for Commandlet view +2+^a| [[cross-platform-61-90-days]] *61-90 days* + | link:https://github.com/devonfw/IDEasy/issues/2142[#2142] | Move IDE-specific metada (.idea, .vscode) out of workspace -2+^a| [[cross-platform-61-90-days]] *61-90 days* - | link:https://github.com/devonfw/IDEasy/issues/2111[#2111] | Add user documentation for GUI @@ -327,18 +399,12 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/1785[#1785] | Implemenent confirmation modals using JavaFX -| link:https://github.com/devonfw/IDEasy/issues/1721[#1721] -| Integrate ruby - | link:https://github.com/devonfw/IDEasy/issues/1695[#1695] | Clone settings to temporary directory, analyse, and then move | link:https://github.com/devonfw/IDEasy/issues/1634[#1634] | UrlUpdater creates error entries for HTTP status 200 -| link:https://github.com/devonfw/IDEasy/issues/1628[#1628] -| Support for OS specific CVEs - | link:https://github.com/devonfw/IDEasy/issues/1580[#1580] | Implement cleanup @@ -408,9 +474,6 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/930[#930] | Refactor `JsonPrettyPrinter` to be usable in CustomToolJson -| link:https://github.com/devonfw/IDEasy/issues/892[#892] -| Improve GitContextTest tests to actually test something - | link:https://github.com/devonfw/IDEasy/issues/891[#891] | Improve Url Updater Report Overview @@ -420,9 +483,6 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/840[#840] | git fetch does not work with multiple remotes -| link:https://github.com/devonfw/IDEasy/issues/787[#787] -| Provide variable to disable open new workspace - | link:https://github.com/devonfw/IDEasy/issues/785[#785] | Re-introduce workspace reverse options from devonfw-ide From 9d1bd23223113e0e65c2090a6a0eed8cb443b267 Mon Sep 17 00:00:00 2001 From: Ali Shariati Najafabadi <89353884+Ali-Shariati-Najafabadi@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:31:21 +0200 Subject: [PATCH 04/66] #2252: add uninstall support for globally installed tools on macOS (#2376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jörg Hohwiller --- CHANGELOG.adoc | 1 + .../tools/ide/tool/GlobalToolCommandlet.java | 78 ++++++++- .../tools/ide/tool/NativePackageManager.java | 52 +++++- .../devonfw/tools/ide/tool/docker/Docker.java | 9 +- .../tools/ide/tool/pgadmin/PgAdmin.java | 30 ++-- .../ide/tool/GlobalToolCommandletTest.java | 155 ++++++++++++++++++ .../ide/tool/NativePackageManagerTest.java | 45 +++++ 7 files changed, 349 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index f74e69c8..7162e865 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -10,6 +10,7 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/2178[#2178]: Make ReleaseCommandlet independent of specific build commandlet and fix `ide build` using npm instead of yarn * https://github.com/devonfw/IDEasy/issues/2142[#2142]: Move IDE-specific metadata (.idea, .vscode) out of workspace * https://github.com/devonfw/IDEasy/issues/989[#989]: Allow expressions in template variable definitions +* https://github.com/devonfw/IDEasy/issues/2252[#2252]: Add uninstall support for globally installed tools on macOS * https://github.com/devonfw/IDEasy/issues/1628[#1628]: Support for OS-specific CVEs The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/50?closed=1[milestone 2026.09.002]. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java index fb276ce3..3061e6d3 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java @@ -32,6 +32,10 @@ public abstract class GlobalToolCommandlet extends ToolCommandlet { private static final Logger LOG = LoggerFactory.getLogger(GlobalToolCommandlet.class); + private static final String MAC_APPLICATIONS_FOLDER_NAME = "Applications"; + + private static final Path MAC_SYSTEM_APPLICATIONS_DIR = Path.of("/" + MAC_APPLICATIONS_FOLDER_NAME); + /** * The constructor. * @@ -85,11 +89,13 @@ protected boolean runWithPackageManager(boolean silent, List applicationsDirs = List.of(MAC_SYSTEM_APPLICATIONS_DIR, this.context.getUserHome().resolve(MAC_APPLICATIONS_FOLDER_NAME)); + for (Path applicationsDir : applicationsDirs) { + Path candidate = applicationsDir.resolve(bundleFileName); + if (Files.isDirectory(candidate)) { + return candidate; + } + } + return null; + } + + /** + * @return the name (without the ".app" suffix) of this tool's application bundle as it appears in the macOS Applications folder, or {@code null} if + * unknown so that {@link #uninstall() uninstall} cannot try to automatically remove it and instead gives the user manual guidance. Override this in + * subclasses that know their application bundle name (which may differ from {@link #getName() the tool name}, e.g. "Docker" for the tool "docker"). + */ + public String getMacApplicationName() { + return null; + } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/NativePackageManager.java b/cli/src/main/java/com/devonfw/tools/ide/tool/NativePackageManager.java index a742228c..cf505d95 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/NativePackageManager.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/NativePackageManager.java @@ -8,30 +8,41 @@ */ public enum NativePackageManager { /** Advanced Package Tool (APT) is the package manager of Debian based Linux distributions. */ - APT("install -y", "-y autoremove --purge", "=", "*"), + APT("apt", "install -y", "-y autoremove --purge", "=", "*", true), /** Zypper is the package manager of SUSE based Linux distributions. */ - ZYPPER("--non-interactive install", "remove", "=", ""), + ZYPPER("zypper", "--non-interactive install", "remove", "=", "", true), /** Yellowdog Updater Modified (YUM) is the package manager of RPM package based Linux distributions like Fedora, Red Hat, or CentOS. */ - YUM("install -y", "remove -y", "-", "*"), + YUM("yum", "install -y", "remove -y", "-", "*", true), /** DaNdiFied yum (DNF) is the package manager of RPM package based Linux distributions like Fedora. It is the successor of {@link #YUM}. */ - DNF("install -y", "remove -y", "-", "*"); + DNF("dnf", "install -y", "remove -y", "-", "*", true), + + /** Homebrew formula installation, the closest thing macOS has to a standard package manager. */ + BREW("brew", "install", "uninstall", "@", "", false), + + /** Homebrew cask installation, used for macOS GUI applications distributed as *.app bundles. */ + BREW_CASK("brew", "install --cask", "uninstall --cask", "@", "", false); private static final String DPKG_STATUS_INSTALLED = "installed"; private static final String SUDO = "sudo"; + private final String binaryName; private final String installCommand; private final String uninstallCommand; private final String versionSeparator; private final String versionWildCard; + private final boolean needSudo; - NativePackageManager(String installCommand, String uninstallCommand, String versionSeparator, String versionWildCard) { + NativePackageManager(String binaryName, String installCommand, String uninstallCommand, String versionSeparator, String versionWildCard, + boolean needSudo) { + this.binaryName = binaryName; this.installCommand = installCommand; this.uninstallCommand = uninstallCommand; this.versionSeparator = versionSeparator; this.versionWildCard = versionWildCard; + this.needSudo = needSudo; } /** @@ -61,7 +72,16 @@ public static NativePackageManager extractPackageManager(String command) { public String getBinaryName() { - return name().toLowerCase(); + return this.binaryName; + } + + /** + * @return {@code true} if commands of this {@link NativePackageManager} need to be run with {@code sudo} (root permissions), {@code false} otherwise (e.g. + * for {@link #BREW}/{@link #BREW_CASK} that must never be run as root). + */ + public boolean isNeedSudo() { + + return this.needSudo; } /** @@ -91,6 +111,8 @@ public List getVersionQueryCommand(String pkg) { List command = new ArrayList<>(switch (this) { case APT -> List.of("dpkg-query", "-W", "-f=${db:Status-Status}|${Version}"); case ZYPPER, YUM, DNF -> List.of("rpm", "-q", "--queryformat", "%{VERSION}"); + case BREW -> List.of(getBinaryName(), "list", "--versions"); + case BREW_CASK -> List.of(getBinaryName(), "list", "--cask", "--versions"); }); command.add(pkg); return command; @@ -111,6 +133,12 @@ public String parseVersionQueryOutput(String output) { return null; } version = parts[1].trim(); + } else if ((this == BREW) || (this == BREW_CASK)) { + // output of "brew list --versions " is " " (possibly multiple space-separated versions, we take the last/newest one) + int lastSpace = version.lastIndexOf(' '); + if (lastSpace >= 0) { + version = version.substring(lastSpace + 1).trim(); + } } return version.isEmpty() ? null : version; } @@ -123,7 +151,11 @@ public String parseVersionQueryOutput(String output) { public PackageManagerCommand install(NativePackage nativePackage, String version) { verifyPackageManager(nativePackage); List commands = new ArrayList<>(nativePackage.getSetupCommands()); - StringBuilder command = new StringBuilder(SUDO).append(' ').append(getBinaryName()); + StringBuilder command = new StringBuilder(); + if (this.needSudo) { + command.append(SUDO).append(' '); + } + command.append(getBinaryName()); for (String option : nativePackage.getExtraInstallOptions()) { command.append(' ').append(option); } @@ -142,7 +174,11 @@ public PackageManagerCommand install(NativePackage nativePackage, String version */ public PackageManagerCommand uninstall(NativePackage nativePackage) { verifyPackageManager(nativePackage); - StringBuilder command = new StringBuilder(SUDO).append(' ').append(getBinaryName()).append(' ').append(this.uninstallCommand); + StringBuilder command = new StringBuilder(); + if (this.needSudo) { + command.append(SUDO).append(' '); + } + command.append(getBinaryName()).append(' ').append(this.uninstallCommand); for (String pkg : nativePackage.getPackages()) { command.append(' ').append(pkg); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/docker/Docker.java b/cli/src/main/java/com/devonfw/tools/ide/tool/docker/Docker.java index 5e924d6e..33209924 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/docker/Docker.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/docker/Docker.java @@ -92,10 +92,17 @@ protected List getNativePackages() { "sudo rm -f /etc/apt/sources.list.d/isv-rancher-stable.list", "sudo rm -f /usr/share/keyrings/isv-rancher-stable-archive-keyring.gpg" ) - ) + ), + new NativePackage(NativePackageManager.BREW_CASK, List.of("docker")) ); } + @Override + public String getMacApplicationName() { + + return "Docker"; + } + @Override public boolean isExtract() { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/pgadmin/PgAdmin.java b/cli/src/main/java/com/devonfw/tools/ide/tool/pgadmin/PgAdmin.java index a07f1bce..0c016271 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/pgadmin/PgAdmin.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/pgadmin/PgAdmin.java @@ -32,16 +32,26 @@ public PgAdmin(IdeContext context) { @Override protected List getNativePackages() { - return List.of(new NativePackage( - NativePackageManager.APT, - List.of("pgadmin4", "pgadmin4-server", "pgadmin4-desktop", "pgadmin4-web"), - List.of("--allow-downgrades"), - List.of("curl -fsS https://www.pgadmin.org/static/packages_pgadmin_org.pub | sudo gpg --yes --dearmor -o /usr/share/keyrings/packages-pgadmin-org.gpg", - "sudo sh -c 'echo \"deb [signed-by=/usr/share/keyrings/packages-pgadmin-org.gpg] " - + "https://ftp.postgresql.org/pub/pgadmin/pgadmin4/apt/$(lsb_release -cs) pgadmin4 main\" " - + "> /etc/apt/sources.list.d/pgadmin4.list && apt update'"), - List.of("sudo rm -f /etc/apt/sources.list.d/pgadmin4.list", "sudo rm -f /usr/share/keyrings/packages-pgadmin-org.gpg") - )); + return List.of( + new NativePackage( + NativePackageManager.APT, + List.of("pgadmin4", "pgadmin4-server", "pgadmin4-desktop", "pgadmin4-web"), + List.of("--allow-downgrades"), + List.of( + "curl -fsS https://www.pgadmin.org/static/packages_pgadmin_org.pub | sudo gpg --yes --dearmor -o /usr/share/keyrings/packages-pgadmin-org.gpg", + "sudo sh -c 'echo \"deb [signed-by=/usr/share/keyrings/packages-pgadmin-org.gpg] " + + "https://ftp.postgresql.org/pub/pgadmin/pgadmin4/apt/$(lsb_release -cs) pgadmin4 main\" " + + "> /etc/apt/sources.list.d/pgadmin4.list && apt update'"), + List.of("sudo rm -f /etc/apt/sources.list.d/pgadmin4.list", "sudo rm -f /usr/share/keyrings/packages-pgadmin-org.gpg") + ), + new NativePackage(NativePackageManager.BREW_CASK, List.of("pgadmin4")) + ); + } + + @Override + public String getMacApplicationName() { + + return "pgAdmin 4"; } @Override diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java index ede37217..c3ada193 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java @@ -1,14 +1,22 @@ package com.devonfw.tools.ide.tool; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.EnumSet; import java.util.List; import java.util.Set; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.log.IdeLogEntry; import com.devonfw.tools.ide.os.SystemInfoMock; import com.devonfw.tools.ide.os.WindowsAppInstallation; import com.devonfw.tools.ide.os.WindowsHelperMock; @@ -199,4 +207,151 @@ void testGetUninstallPackageManagerCommandsDerivesFromNativePackages() { "sudo apt -y autoremove --purge mytool", "sudo rm -f /etc/apt/sources.list.d/mytool.list"); } + + /** + * Dummy {@link GlobalToolCommandlet} that declares a Homebrew cask for testing macOS uninstall via {@link NativePackageManager#BREW_CASK}. + */ + static class BrewCaskToolCommandlet extends GlobalToolCommandlet { + + private static final String TOOL_NAME = "mytool"; + + BrewCaskToolCommandlet(IdeContext context) { + + super(context, TOOL_NAME, Set.of(Tag.MISC)); + } + + @Override + protected List getNativePackages() { + + return List.of(new NativePackage(NativePackageManager.BREW_CASK, List.of(TOOL_NAME))); + } + + @Override + protected String getBinaryName() { + return TOOL_NAME; + } + } + + /** + * Verifies that {@link GlobalToolCommandlet#getUninstallPackageManagerCommands()} derives a Homebrew cask uninstall command without a {@code sudo} prefix + * (Homebrew must never be run as root). + */ + @Test + void testGetUninstallPackageManagerCommandsDerivesBrewCaskWithoutSudo() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.MAC_X64); + BrewCaskToolCommandlet commandlet = new BrewCaskToolCommandlet(context); + + // act + List uninstallCommands = commandlet.getUninstallPackageManagerCommands(); + + // assert + assertThat(uninstallCommands).hasSize(1); + PackageManagerCommand cmd = uninstallCommands.getFirst(); + assertThat(cmd.packageManager()).isEqualTo(NativePackageManager.BREW_CASK); + assertThat(cmd.commands()).containsExactly("brew uninstall --cask mytool"); + } + + /** + * Dummy {@link GlobalToolCommandlet} that declares a known macOS application bundle name but no package manager, for testing the *.app removal fallback of + * {@link GlobalToolCommandlet#uninstall()}. + */ + static class MacAppBundleToolCommandlet extends GlobalToolCommandlet { + + private static final String TOOL_NAME = "mytool"; + + MacAppBundleToolCommandlet(IdeContext context) { + + super(context, TOOL_NAME, Set.of(Tag.MISC)); + } + + @Override + protected String getBinaryName() { + return TOOL_NAME; + } + + @Override + public String getMacApplicationName() { + return "MyTool"; + } + } + + /** + * Verifies that on macOS, when no package manager can uninstall the tool, {@link GlobalToolCommandlet#uninstall()} removes the known *.app bundle from the + * user's Applications folder. + */ + @Test + void testUninstallOnMacRemovesKnownApplicationBundle() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.MAC_X64); + MacAppBundleToolCommandlet commandlet = new MacAppBundleToolCommandlet(context); + Path appBundle = context.getUserHome().resolve("Applications").resolve("MyTool.app"); + context.getFileAccess().mkdirs(appBundle); + + // act + commandlet.uninstall(); + + // assert + assertThat(appBundle).doesNotExist(); + assertThat(context).log().hasEntries(IdeLogEntry.ofSuccess("Successfully uninstalled mytool by removing " + appBundle)); + } + + /** + * Verifies that on macOS, when the known *.app bundle exists but cannot be deleted (e.g. because - since macOS Monterey - the Applications folder is + * protected and denies the operation), {@link GlobalToolCommandlet#uninstall()} does not let the resulting exception propagate but falls back to logging + * manual-uninstall guidance instead. + */ + @Test + @DisabledOnOs(OS.WINDOWS) + void testUninstallOnMacFallsBackToGuidanceWhenApplicationBundleCannotBeDeleted() throws IOException { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.MAC_X64); + MacAppBundleToolCommandlet commandlet = new MacAppBundleToolCommandlet(context); + Path applicationsDir = context.getUserHome().resolve("Applications"); + Path appBundle = applicationsDir.resolve("MyTool.app"); + context.getFileAccess().mkdirs(appBundle); + // deleting an entry requires write permission on its *parent* directory - remove it to simulate macOS denying the deletion + Files.setPosixFilePermissions(applicationsDir, EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_EXECUTE)); + + try { + // act + commandlet.uninstall(); + + // assert: deletion failed so the bundle is still there and we fell back to manual guidance instead of crashing + assertThat(appBundle).exists(); + assertThat(context).logAtWarning().hasMessageContaining("Could not automatically remove " + appBundle); + assertThat(context).logAtError().hasMessageContaining("Couldn't automatically uninstall mytool on macOS"); + } finally { + // restore write permission so the temp directory can be cleaned up afterwards + Files.setPosixFilePermissions(applicationsDir, + EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE)); + } + } + + /** + * Verifies that on macOS, when neither a package manager nor a known *.app bundle can be found, {@link GlobalToolCommandlet#uninstall()} logs actionable + * manual-uninstall guidance instead of the previous generic "uninstall manually" error. + */ + @Test + void testUninstallOnMacLogsManualGuidanceWhenNothingFound() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.MAC_X64); + AsyncInstallerToolCommandlet commandlet = new AsyncInstallerToolCommandlet(context); + + // act + commandlet.uninstall(); + + // assert + assertThat(context).logAtError().hasMessageContaining( + "Couldn't automatically uninstall " + TOOL_NAME + " on macOS. Please uninstall it manually, e.g. by moving it from the Applications folder to the " + + "Trash"); + } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/NativePackageManagerTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/NativePackageManagerTest.java index b57c8d8e..605a2f93 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/NativePackageManagerTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/NativePackageManagerTest.java @@ -192,4 +192,49 @@ void testParseVersionQueryOutputForRpmBasedPackageManager() { assertThat(NativePackageManager.YUM.parseVersionQueryOutput("1.0.0")).isEqualTo("1.0.0"); assertThat(NativePackageManager.DNF.parseVersionQueryOutput("1.0.0")).isEqualTo("1.0.0"); } + + @Test + void testBrewInstallAndUninstallCommandsDoNotUseSudo() { + NativePackage np = NativePackage.of(NativePackageManager.BREW, "pkg1"); + + var installCmd = NativePackageManager.BREW.install(np, "1.0.0"); + var uninstallCmd = NativePackageManager.BREW.uninstall(np); + + assertThat(installCmd.commands()).containsExactly("brew install pkg1@1.0.0"); + assertThat(uninstallCmd.commands()).containsExactly("brew uninstall pkg1"); + } + + @Test + void testBrewCaskInstallAndUninstallCommandsDoNotUseSudo() { + NativePackage np = NativePackage.of(NativePackageManager.BREW_CASK, "docker"); + + var installCmd = NativePackageManager.BREW_CASK.install(np, null); + var uninstallCmd = NativePackageManager.BREW_CASK.uninstall(np); + + assertThat(installCmd.commands()).containsExactly("brew install --cask docker"); + assertThat(uninstallCmd.commands()).containsExactly("brew uninstall --cask docker"); + } + + @Test + void testVersionQueryCommandForBrew() { + assertThat(NativePackageManager.BREW.getVersionQueryCommand("pkg1")).containsExactly("brew", "list", "--versions", "pkg1"); + assertThat(NativePackageManager.BREW_CASK.getVersionQueryCommand("pkg1")).containsExactly("brew", "list", "--cask", "--versions", "pkg1"); + } + + @Test + void testParseVersionQueryOutputForBrew() { + assertThat(NativePackageManager.BREW.parseVersionQueryOutput("pkg1 1.2.3")).isEqualTo("1.2.3"); + assertThat(NativePackageManager.BREW_CASK.parseVersionQueryOutput("docker 24.0.0")).isEqualTo("24.0.0"); + assertThat(NativePackageManager.BREW.parseVersionQueryOutput("")).isNull(); + } + + @Test + void testIsNeedSudo() { + assertThat(NativePackageManager.APT.isNeedSudo()).isTrue(); + assertThat(NativePackageManager.ZYPPER.isNeedSudo()).isTrue(); + assertThat(NativePackageManager.YUM.isNeedSudo()).isTrue(); + assertThat(NativePackageManager.DNF.isNeedSudo()).isTrue(); + assertThat(NativePackageManager.BREW.isNeedSudo()).isFalse(); + assertThat(NativePackageManager.BREW_CASK.isNeedSudo()).isFalse(); + } } From 99b4b45db21f7e8075df4d9e408522e76351ecdb Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 15 Sep 2026 11:22:02 +0200 Subject: [PATCH 05/66] #1695: Clone settings to temporary directory, analyse, and then move (#2335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: laim2003 Co-authored-by: Jörg Hohwiller --- CHANGELOG.adoc | 1 + .../devonfw/tools/ide/cli/CliException.java | 11 + .../tools/ide/cli/CliFatalException.java | 36 ++ .../ide/commandlet/CommandletManagerImpl.java | 1 + .../ide/commandlet/CreateCommandlet.java | 51 +-- .../ide/commandlet/StatusCommandlet.java | 6 +- .../AbstractUpdateCommandlet.java | 199 ++++------- .../{ => update}/UpdateCommandlet.java | 3 +- .../settings/SettingsHealthCheckResult.java | 42 +++ .../update/settings/SettingsUpdateResult.java | 10 + .../update/settings/SettingsUpdateStatus.java | 11 + .../update/settings/SettingsUpdater.java | 321 ++++++++++++++++++ .../tools/ide/context/AbstractIdeContext.java | 30 +- .../devonfw/tools/ide/context/IdeContext.java | 11 +- .../ide/git/repository/RepositoryType.java | 98 ++++++ .../java/com/devonfw/tools/ide/step/Step.java | 29 +- cli/src/main/resources/nls/Help.properties | 1 - cli/src/main/resources/nls/Help_de.properties | 1 - .../ide/commandlet/CreateCommandletTest.java | 105 +++--- .../ide/commandlet/UpdateCommandletTest.java | 58 +++- .../devonfw/tools/ide/git/GitContextMock.java | 11 +- .../com/devonfw/tools/ide/step/StepTest.java | 65 ++++ .../code-settings/settings/ide.properties | 1 + .../test/resources/settings/ide.properties | 0 documentation/settings.adoc | 19 +- 25 files changed, 876 insertions(+), 245 deletions(-) create mode 100644 cli/src/main/java/com/devonfw/tools/ide/cli/CliFatalException.java rename cli/src/main/java/com/devonfw/tools/ide/commandlet/{ => update}/AbstractUpdateCommandlet.java (66%) rename cli/src/main/java/com/devonfw/tools/ide/commandlet/{ => update}/UpdateCommandlet.java (85%) create mode 100644 cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsHealthCheckResult.java create mode 100644 cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdateResult.java create mode 100644 cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdateStatus.java create mode 100644 cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdater.java create mode 100644 cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryType.java create mode 100644 cli/src/test/resources/code-settings/settings/ide.properties create mode 100644 cli/src/test/resources/settings/ide.properties diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 7162e865..d70b6006 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -6,6 +6,7 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDE Release with new features and bugfixes: +* https://github.com/devonfw/IDEasy/issues/1695[#1695]: Project creation logic extended with health checks and removed `--code` flag. * https://github.com/devonfw/IDEasy/issues/2190[#2190]: IDEasy destroys my python installation * https://github.com/devonfw/IDEasy/issues/2178[#2178]: Make ReleaseCommandlet independent of specific build commandlet and fix `ide build` using npm instead of yarn * https://github.com/devonfw/IDEasy/issues/2142[#2142]: Move IDE-specific metadata (.idea, .vscode) out of workspace diff --git a/cli/src/main/java/com/devonfw/tools/ide/cli/CliException.java b/cli/src/main/java/com/devonfw/tools/ide/cli/CliException.java index 16cb0598..3a39a319 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/cli/CliException.java +++ b/cli/src/main/java/com/devonfw/tools/ide/cli/CliException.java @@ -64,4 +64,15 @@ public int getExitCode() { return this.exitCode; } + /** + * @return {@code true} if this exception has to be re-thrown from a {@link com.devonfw.tools.ide.step.Step Step} even if that {@code Step} was not asked to + * re-throw errors, {@code false} otherwise (default). A regular error only makes the according {@code Step} fail while the overall process continues with + * the next step. However, if a critical guardrail was violated (e.g. no valid settings could be established) continuing makes no sense and the entire + * process has to be aborted. + */ + public boolean isForceRethrowInStep() { + + return false; + } + } diff --git a/cli/src/main/java/com/devonfw/tools/ide/cli/CliFatalException.java b/cli/src/main/java/com/devonfw/tools/ide/cli/CliFatalException.java new file mode 100644 index 00000000..11a153aa --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/cli/CliFatalException.java @@ -0,0 +1,36 @@ +package com.devonfw.tools.ide.cli; + +/** + * {@link CliException} that aborts the entire CLI process when a critical guardrail fails (e.g. the settings repository could not be cloned or is not a valid + * settings repository). Unlike a regular error that only makes the current {@link com.devonfw.tools.ide.step.Step Step} fail while the overall process + * continues, this exception {@link #isForceRethrowInStep() is always re-thrown} so no further step is executed in an invalid state. + */ +public final class CliFatalException extends CliException { + + /** + * The constructor. + * + * @param message the {@link #getMessage() message}. + */ + public CliFatalException(String message) { + + super(message); + } + + /** + * The constructor. + * + * @param message the {@link #getMessage() message}. + * @param cause the {@link #getCause() cause}. + */ + public CliFatalException(String message, Throwable cause) { + + super(message, cause); + } + + @Override + public boolean isForceRethrowInStep() { + + return true; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java index c42d462b..40e81f3b 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java @@ -15,6 +15,7 @@ import com.devonfw.tools.ide.cli.CliArgument; import com.devonfw.tools.ide.cli.CliArguments; import com.devonfw.tools.ide.commandlet.cleanup.CleanupCommandlet; +import com.devonfw.tools.ide.commandlet.update.UpdateCommandlet; import com.devonfw.tools.ide.completion.CompletionCandidateCollector; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.git.repository.RepositoryCommandlet; diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CreateCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CreateCommandlet.java index a68d6768..b8681858 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CreateCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CreateCommandlet.java @@ -7,10 +7,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.devonfw.tools.ide.commandlet.update.AbstractUpdateCommandlet; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.io.FileAccess; import com.devonfw.tools.ide.log.IdeLogLevel; -import com.devonfw.tools.ide.property.FlagProperty; import com.devonfw.tools.ide.property.StringProperty; import com.devonfw.tools.ide.version.IdeVersion; @@ -24,9 +24,6 @@ public class CreateCommandlet extends AbstractUpdateCommandlet { /** {@link StringProperty} for the name of the new project */ public final StringProperty newProject; - /** {@link FlagProperty} for creating a project with settings inside a code repository */ - public final FlagProperty codeRepositoryFlag; - /** * The constructor. * @@ -36,7 +33,6 @@ public CreateCommandlet(IdeContext context) { super(context); this.newProject = add(new StringProperty("", true, "project")); - this.codeRepositoryFlag = add(new FlagProperty("--code")); add(this.settingsRepo); } @@ -55,42 +51,51 @@ public boolean isIdeHomeRequired() { @Override protected void doRun() { - String newProjectName = this.newProject.getValue(); - Path newProjectPath = this.context.getIdeRoot().resolve(newProjectName); - + Path newProjectPath = getNewProjectPath(); LOG.info("Creating new IDEasy project in {}", newProjectPath); - if (!this.context.getFileAccess().isEmptyDir(newProjectPath)) { - this.context.askToContinue("Directory {} already exists. Do you want to continue?", newProjectPath); - } else { - this.context.getFileAccess().mkdirs(newProjectPath); + FileAccess fileAccess = this.context.getFileAccess(); + if (!fileAccess.isEmptyDir(newProjectPath)) { + this.context.askToContinue("Directory {} already exists. Do you want to move the project to the backup and create it from scratch?", newProjectPath); + fileAccess.backup(newProjectPath); } - initializeProject(newProjectPath); - this.context.setIdeHome(newProjectPath); super.doRun(); this.context.getFileAccess().writeFileContent(IdeVersion.getVersionString(), newProjectPath.resolve(IdeContext.FILE_SOFTWARE_VERSION)); - IdeLogLevel.SUCCESS.log(LOG, "Successfully created new project '{}'.", newProjectName); - + IdeLogLevel.SUCCESS.log(LOG, "Successfully created new project '{}'.", this.newProject.getValue()); logWelcomeMessage(); } - private void initializeProject(Path newInstancePath) { + @Override + protected void onSettingHealthCheckFinished() { + // only called after the settings passed the health check + Path newProjectPath = getNewProjectPath(); + this.context.setIdeHome(newProjectPath); FileAccess fileAccess = this.context.getFileAccess(); - fileAccess.mkdirs(newInstancePath.resolve(IdeContext.FOLDER_SOFTWARE)); - fileAccess.mkdirs(newInstancePath.resolve(IdeContext.FOLDER_PLUGINS)); - fileAccess.mkdirs(newInstancePath.resolve(IdeContext.FOLDER_WORKSPACES).resolve(IdeContext.WORKSPACE_MAIN)); + fileAccess.mkdirs(newProjectPath); + fileAccess.mkdirs(newProjectPath.resolve(IdeContext.FOLDER_SOFTWARE)); + fileAccess.mkdirs(newProjectPath.resolve(IdeContext.FOLDER_PLUGINS)); + fileAccess.mkdirs(newProjectPath.resolve(IdeContext.FOLDER_WORKSPACES).resolve(IdeContext.WORKSPACE_MAIN)); } + /** + * @return The path to the settings folder in the new project. + */ @Override - protected boolean isCodeRepository() { - return this.codeRepositoryFlag.isTrue(); + protected Path getSettingsPathForSettingsUpdate() { + + return getNewProjectPath().resolve(IdeContext.FOLDER_SETTINGS); + } + + private Path getNewProjectPath() { + + return this.context.getIdeRoot().resolve(this.newProject.getValue()); } @Override protected String getStepMessage() { - return "Create (clone) " + (isCodeRepository() ? "code" : "settings") + " repository"; + return "Create (clone) repository"; } private void logWelcomeMessage() { diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/StatusCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/StatusCommandlet.java index 39a5131f..71fe06e0 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/StatusCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/StatusCommandlet.java @@ -9,6 +9,7 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.environment.EnvironmentVariables; import com.devonfw.tools.ide.git.GitContext; +import com.devonfw.tools.ide.git.repository.RepositoryType; import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.migration.IdeMigrator; import com.devonfw.tools.ide.os.SystemInfo; @@ -91,7 +92,8 @@ private void logSettingsLegacyStatus() { } if (hasLegacyProperties) { LOG.warn( - "Your settings are outdated and contain legacy configurations. Please consider upgrading your settings:\nhttps://github.com/devonfw/IDEasy/blob/main/documentation/settings.adoc#upgrade"); + "Your settings are outdated and contain legacy configurations. " + + "Please consider upgrading your settings:\nhttps://github.com/devonfw/IDEasy/blob/main/documentation/settings.adoc#upgrade"); } } @@ -104,7 +106,7 @@ private void logSettingsGitStatus() { } else { GitContext gitContext = this.context.getGitContext(); if (gitContext.isRepositoryUpdateAvailable(settingsPath, this.context.getSettingsCommitIdPath())) { - if (!this.context.isSettingsCodeRepository()) { + if (RepositoryType.ofSettingsPath(this.context.getSettingsPath(), context) != RepositoryType.CODE_SETTINGS_COMBINED) { LOG.warn("Your settings are not up-to-date, please run 'ide update'."); } } else { diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractUpdateCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/AbstractUpdateCommandlet.java similarity index 66% rename from cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractUpdateCommandlet.java rename to cli/src/main/java/com/devonfw/tools/ide/commandlet/update/AbstractUpdateCommandlet.java index c034ddcf..96f525a5 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractUpdateCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/AbstractUpdateCommandlet.java @@ -1,4 +1,4 @@ -package com.devonfw.tools.ide.commandlet; +package com.devonfw.tools.ide.commandlet.update; import java.io.IOException; import java.nio.file.Files; @@ -6,6 +6,7 @@ import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.stream.Stream; @@ -13,12 +14,19 @@ import org.slf4j.LoggerFactory; import com.devonfw.tools.ide.cli.CliException; +import com.devonfw.tools.ide.cli.CliFatalException; +import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.CommandletManager; +import com.devonfw.tools.ide.commandlet.CreateCommandlet; +import com.devonfw.tools.ide.commandlet.update.settings.SettingsHealthCheckResult; +import com.devonfw.tools.ide.commandlet.update.settings.SettingsUpdateResult; +import com.devonfw.tools.ide.commandlet.update.settings.SettingsUpdateStatus; +import com.devonfw.tools.ide.commandlet.update.settings.SettingsUpdater; import com.devonfw.tools.ide.context.AbstractIdeContext; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.context.IdeStartContextImpl; -import com.devonfw.tools.ide.git.GitContext; -import com.devonfw.tools.ide.git.GitUrl; import com.devonfw.tools.ide.git.repository.RepositoryCommandlet; +import com.devonfw.tools.ide.git.repository.RepositoryType; import com.devonfw.tools.ide.io.FileAccess; import com.devonfw.tools.ide.property.FlagProperty; import com.devonfw.tools.ide.property.StringProperty; @@ -43,17 +51,6 @@ public abstract class AbstractUpdateCommandlet extends Commandlet { private static final Logger LOG = LoggerFactory.getLogger(AbstractUpdateCommandlet.class); - private static final String MESSAGE_CODE_REPO_URL = """ - No code repository was given after '--code'. - Further details can be found here: https://github.com/devonfw/IDEasy/blob/main/documentation/settings.adoc - Please enter the code repository below that includes your settings folder."""; - - private static final String MESSAGE_SETTINGS_REPO_URL = """ - No settings found at {} and no SETTINGS_URL is defined. - Further details can be found here: https://github.com/devonfw/IDEasy/blob/main/documentation/settings.adoc - Please contact the technical lead of your project to get the SETTINGS_URL for your project to enter. - In case you just want to test IDEasy you may simply hit return to install the default settings."""; - /** {@link StringProperty} for the settings repository URL. */ public final StringProperty settingsRepo; @@ -107,6 +104,15 @@ protected void doRun() { createStartScripts(); } + /** + * Hook that is called after the settings passed the health check but before they are moved to their final location. Does nothing by default and is overridden + * by {@link CreateCommandlet} to create the project structure so that no project is created at all if the health check failed. + */ + protected void onSettingHealthCheckFinished() { + + // nothing to do by default + } + private void reloadContext() { ((AbstractIdeContext) this.context).reload(); @@ -158,138 +164,83 @@ private void setupConf(Path template, Path conf) { /** * Updates the settings repository in IDE_HOME/settings by either cloning if no such repository exists or pulling if the repository exists then saves the - * latest current commit ID in the file ".commit.id". + * latest current commit ID in the file ".commit.id". The settings are always cloned into a temporary directory first where a health check is performed. Only + * if that health check succeeded the settings are pulled or the verified clone is moved to its final location. */ protected void updateSettings() { - boolean codeRepository = this.context.isSettingsCodeRepository(); - if (codeRepository && !(this.context.isForceMode() || forcePull.isTrue())) { + RepositoryType repositoryType = RepositoryType.ofSettingsPath(getSettingsPathForSettingsUpdate(), context); + if (repositoryType == RepositoryType.CODE_SETTINGS_COMBINED && !(this.context.isForceMode() || this.forcePull.isTrue())) { LOG.info("Skipping git pull in settings due to code repository. Use --force-pull to enforce pulling."); return; } - this.context.newStep(getStepMessage()).run(() -> updateSettingsInStep(codeRepository)); + Step step = this.context.newStep(getStepMessage()); + step.run(this::updateSettingsInStep); } protected String getStepMessage() { - return "update (pull) settings repository"; + return "Update settings repository"; } - private void updateSettingsInStep(boolean codeRepository) { - Path settingsPath = this.context.getSettingsPath(); - if (!codeRepository) { - boolean settingsRepository = this.context.getGitContext().isGitRepo(settingsPath); - if (!settingsRepository) { - if (Files.exists(settingsPath)) { - if (!this.context.getFileAccess().isEmptyDir(settingsPath)) { - this.context.askToContinue( - "Your settings repository seems to be broken ('.git' folder not present). " - + "We can fix this by moving your settings the backed up. " - + "You will be asked for the settings git URL and your settings will be cloned from scratch. " - + "Do you want to proceed?" - ); - } - this.context.getFileAccess().backup(settingsPath); - } - GitUrl gitUrl = getOrAskSettingsUrl(); - checkProjectNameConvention(gitUrl.getProjectName()); - initializeRepository(gitUrl); - return; - } - } - GitContext gitContext = this.context.getGitContext(); - if (gitContext.hasUntrackedFiles(settingsPath)) { - gitContext.pullSafelyWithStash(settingsPath); - } else { - gitContext.pull(settingsPath); - } - this.context.getGitContext().saveCurrentCommitId(settingsPath, this.context.getSettingsCommitIdPath()); - } + private void updateSettingsInStep() { - private GitUrl getOrAskSettingsUrl() { + SettingsUpdater settingsUpdater = new SettingsUpdater(this.context, this.settingsRepo); + try { + //Step 1: Perform health check + Step healthCheckStep = this.context.newStep("Performing settings health check"); + SettingsHealthCheckResult healthCheckResult = healthCheckStep.call(() -> checkSettingsInStep(settingsUpdater), () -> null); - String repository = this.settingsRepo.getValue(); - repository = handleDefaultRepository(repository); - String userPromt; - String defaultUrl; - if (isCodeRepository()) { - userPromt = "Code repository URL:"; - defaultUrl = null; - LOG.info(MESSAGE_CODE_REPO_URL); - } else { - userPromt = "Settings URL [" + IdeContext.DEFAULT_SETTINGS_REPO_URL + "]:"; - defaultUrl = IdeContext.DEFAULT_SETTINGS_REPO_URL; - LOG.info(MESSAGE_SETTINGS_REPO_URL, this.context.getSettingsPath()); - } - GitUrl gitUrl = null; - if (repository != null) { - gitUrl = GitUrl.of(repository); - } - while ((gitUrl == null) || !gitUrl.isValid()) { - repository = this.context.askForInput(userPromt, defaultUrl); - repository = handleDefaultRepository(repository); - gitUrl = GitUrl.of(repository); - if (!gitUrl.isValid()) { - LOG.warn("The input URL is not valid, please try again."); + // If the health check failed (healthCheckResult is null) the settings have not been verified, so skip applying them and fail the "Update settings" + // step. A non-null result is only produced when the health check passed or the user explicitly chose to continue anyway (force mode), so this never + // aborts in force mode. + if (healthCheckResult == null) { + throw new CliException("Settings update aborted due to error in health check"); } - } - return gitUrl; - } - private String handleDefaultRepository(String repository) { - if ("-".equals(repository)) { - if (isCodeRepository()) { - LOG.warn("'-' is found after '--code'. This is invalid."); - repository = null; - } else { - LOG.info("'-' was found for settings repository, the default settings repository '{}' will be used.", IdeContext.DEFAULT_SETTINGS_REPO_URL); - repository = IdeContext.DEFAULT_SETTINGS_REPO_URL; + //Step 2: Let create/update commandlets prepare themselves for the settings update. + onSettingHealthCheckFinished(); + + //Step 3: Apply (move/pull newest version) settings + Step applySettingsStep = this.context.newStep("Applying settings"); + applySettingsStep.run(() -> applySettingsUpdateInStep(settingsUpdater, healthCheckResult)); + + //Make sure to always fail the parent step if the "Apply settings" step fails. + if (applySettingsStep.isFailure()) { + throw new CliException("Settings update failed due to error while applying the settings update"); } + } finally { + // the verified clone lives across both steps and the prepareProject hook so it is only here that its lifetime ends + settingsUpdater.cleanup(); } - return repository; } - private void checkProjectNameConvention(String projectName) { - boolean isSettingsRepo = projectName.contains(IdeContext.SETTINGS_REPOSITORY_KEYWORD); - boolean codeRepository = isCodeRepository(); - if (isSettingsRepo == codeRepository) { - String warningTemplate; - if (codeRepository) { - warningTemplate = """ - Your git URL is pointing to the project name {} that contains the keyword '{}'. - Therefore we assume that you did a mistake by adding the '--code' option to the ide project creation. - Do you really want to create the project?"""; - } else { - warningTemplate = """ - Your git URL is pointing to the project name {} that does not contain the keyword ''{}''. - Therefore we assume that you forgot to add the '--code' option to the ide project creation. - Do you really want to create the project?"""; - } - this.context.askToContinue(warningTemplate, projectName, IdeContext.SETTINGS_REPOSITORY_KEYWORD); + private SettingsHealthCheckResult checkSettingsInStep(SettingsUpdater settingsUpdater) { + SettingsHealthCheckResult healthCheckResult = settingsUpdater.checkSettings(getSettingsPathForSettingsUpdate()); + RepositoryType repositoryType = healthCheckResult.repositoryType(); + + if ((repositoryType == null || !repositoryType.isValid()) && !healthCheckResult.isExistingProject()) { + throw new CliFatalException("Fatal error while cloning settings: The settings health check failed: " + healthCheckResult.errorMessage()); + } else if ((repositoryType == null || !repositoryType.isValid())) { + throw new CliException("The settings health check failed: " + healthCheckResult.errorMessage()); } + return healthCheckResult; } - private void initializeRepository(GitUrl gitUrl) { + private void applySettingsUpdateInStep(SettingsUpdater settingsUpdater, SettingsHealthCheckResult healthCheckResult) { + SettingsUpdateResult settingsUpdateResult = settingsUpdater.applySettings(healthCheckResult); + if (settingsUpdateResult == null) { - GitContext gitContext = this.context.getGitContext(); - Path settingsPath = this.context.getSettingsPath(); - Path repoPath = settingsPath; - boolean codeRepository = isCodeRepository(); - if (codeRepository) { - // clone the given code repository into IDE_HOME/workspaces/main - repoPath = context.getWorkspacePath().resolve(gitUrl.getProjectName()); + throw new CliException("Failed to apply the settings update due to unknown error."); } - gitContext.pullOrClone(gitUrl, repoPath); - if (codeRepository) { - // check for settings folder and create symlink to IDE_HOME/settings - Path settingsFolder = repoPath.resolve(IdeContext.FOLDER_SETTINGS); - if (Files.exists(settingsFolder)) { - context.getFileAccess().symlink(settingsFolder, settingsPath); - } else { - throw new CliException("Invalid code repository " + gitUrl + ": missing a settings folder at " + settingsFolder); + + if (Objects.requireNonNull(settingsUpdateResult.updateStatus()) == SettingsUpdateStatus.SETTINGS_UPDATE_FAILED) { + String errorMessage = "The settings update could not be applied: " + settingsUpdateResult.errorMessage(); + if (!healthCheckResult.isExistingProject()) { + throw new CliFatalException(errorMessage); } + throw new CliException(errorMessage); } - this.context.getGitContext().saveCurrentCommitId(settingsPath, this.context.getSettingsCommitIdPath()); } private void updateSoftware() { @@ -449,12 +400,12 @@ private void createStartScript(String ide, String workspace) { } /** - * Judge if the repository is a code repository. + * This method returns the path to the settings for the case of a settings update. * - * @return true when the repository is a code repository, otherwise false. + * @return The {@link Path} to the settings folder; if not overridden we will get the path from the context */ - protected boolean isCodeRepository() { - return false; - } + protected Path getSettingsPathForSettingsUpdate() { + return this.context.getSettingsPath(); + } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UpdateCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/UpdateCommandlet.java similarity index 85% rename from cli/src/main/java/com/devonfw/tools/ide/commandlet/UpdateCommandlet.java rename to cli/src/main/java/com/devonfw/tools/ide/commandlet/update/UpdateCommandlet.java index 944a4c0e..a0319dbb 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UpdateCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/UpdateCommandlet.java @@ -1,5 +1,6 @@ -package com.devonfw.tools.ide.commandlet; +package com.devonfw.tools.ide.commandlet.update; +import com.devonfw.tools.ide.commandlet.Commandlet; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.migration.IdeMigrator; diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsHealthCheckResult.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsHealthCheckResult.java new file mode 100644 index 00000000..41dd6d9e --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsHealthCheckResult.java @@ -0,0 +1,42 @@ +package com.devonfw.tools.ide.commandlet.update.settings; + +import java.nio.file.Path; + +import com.devonfw.tools.ide.git.repository.RepositoryType; + +/** + * Result of the settings {@link SettingsUpdater#checkSettings(Path)} health check}. + * + * @param repositoryType the {@link RepositoryType} of the settings repository. + * @param errorMessage the reason why the settings could not be updated or {@code null} if the health check succeeded. + * @param settingsDirectory path to the temporary folder this health check was performed on. + */ +public record SettingsHealthCheckResult(RepositoryType repositoryType, Path settingsDirectory, String errorMessage, + boolean isExistingProject) { + + /** + * @param repositoryType the {@link RepositoryType}. + * @param temporarySettingsDirectory path to the temporary folder this health check was performed on. + * @param isExistingProject whether the settings we checked were freshly cloned or already existed + * @return a {@link SettingsHealthCheckResult} for a successful health check. + */ + public static SettingsHealthCheckResult ofSuccess(RepositoryType repositoryType, Path temporarySettingsDirectory, + boolean isExistingProject) { + + return new SettingsHealthCheckResult(repositoryType, temporarySettingsDirectory, null, isExistingProject); + } + + /** + * @param repositoryType the {@link RepositoryType} of the settings that are already present. + * @param errorMessage the reason why the settings could not be updated. + * @param temporarySettingsDirectory path to the temporary folder this health check was performed on. + * @param isExistingProject whether the settings we checked were cloned freshly or already existed + * @return a {@link SettingsHealthCheckResult} for a failed but recoverable health check. + */ + public static SettingsHealthCheckResult ofFailed(RepositoryType repositoryType, String errorMessage, Path temporarySettingsDirectory, + boolean isExistingProject) { + + return new SettingsHealthCheckResult(repositoryType, temporarySettingsDirectory, errorMessage, + isExistingProject); + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdateResult.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdateResult.java new file mode 100644 index 00000000..87d3fe1e --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdateResult.java @@ -0,0 +1,10 @@ +package com.devonfw.tools.ide.commandlet.update.settings; + +import com.devonfw.tools.ide.git.repository.RepositoryType; + +/** + * @param updateStatus resulting status of the update operation + * @param repositoryType detected type of the repository + * @param errorMessage error message if updateStatus = {@link SettingsUpdateStatus}.SETTINGS_UPDATE_FAILED, otherwise {@code null} + */ +public record SettingsUpdateResult(SettingsUpdateStatus updateStatus, RepositoryType repositoryType, String errorMessage) {} diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdateStatus.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdateStatus.java new file mode 100644 index 00000000..d13871f4 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdateStatus.java @@ -0,0 +1,11 @@ +package com.devonfw.tools.ide.commandlet.update.settings; + +/// Status of the update action of a settings repo. +public enum SettingsUpdateStatus { + /** Existing settings have been successfully updated **/ + SETTINGS_UPDATED, + /** Freshly cloned settings have been successfully applied **/ + SETTINGS_CLONED, + /** Error occurred **/ + SETTINGS_UPDATE_FAILED +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdater.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdater.java new file mode 100644 index 00000000..fda8a8c6 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdater.java @@ -0,0 +1,321 @@ +package com.devonfw.tools.ide.commandlet.update.settings; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.tools.ide.cli.CliAbortException; +import com.devonfw.tools.ide.cli.CliException; +import com.devonfw.tools.ide.cli.CliFatalException; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.git.GitContext; +import com.devonfw.tools.ide.git.GitUrl; +import com.devonfw.tools.ide.git.repository.RepositoryType; +import com.devonfw.tools.ide.io.FileAccess; +import com.devonfw.tools.ide.property.StringProperty; + +/** + * Handles the settings repository of the current project in two phases: + *

    + *
  1. {@link #checkSettings(Path)} health check: the settings are always cloned into a temporary directory + * first where it is verified that the git URL is valid, that cloning succeeded, + * and that the repository actually is a settings or a combined code and settings repository.
  2. + *
  3. {@link #applySettings(SettingsHealthCheckResult)} apply: Settings are pulled in place (if they were already + * present)
  4. + *
+ */ +public class SettingsUpdater { + + private static final Logger LOG = LoggerFactory.getLogger(SettingsUpdater.class); + + private static final String MESSAGE_SETTINGS_REPO_URL = """ + No settings found at {} and no SETTINGS_URL is defined. + Further details can be found here: https://github.com/devonfw/IDEasy/blob/main/documentation/settings.adoc + Please contact the technical lead of your project to get the SETTINGS_URL for your project to enter. + In case you just want to test IDEasy you may simply hit return to install the default settings."""; + + private static final String MESSAGE_INVALID_REPOSITORY = "The given git repository URL does not point to a valid settings or code-settings repository. " + + "Please verify and try again."; + + private final IdeContext context; + + private final FileAccess fileAccess; + + private final StringProperty settingsRepoProperty; + + /** The temporary directory holding the verified clone or {@code null} if there is nothing to move. */ + private Path tempRepoDir; + + /** The name of the git project - required to place a combined code and settings repository into the workspace. */ + private String gitProjectName; + + /** + * The constructor. + * + * @param context the {@link IdeContext}. + * @param settingsRepoProperty the {@link StringProperty} with the settings repository URL from the update commandlet. + */ + public SettingsUpdater(IdeContext context, StringProperty settingsRepoProperty) { + + super(); + this.context = context; + this.settingsRepoProperty = settingsRepoProperty; + this.fileAccess = context.getFileAccess(); + } + + /** + * Performs the health check on the settings repository. Nothing is changed in {@link IdeContext#getIdeHome() IDE_HOME} except that a broken settings folder + * is backed up. Whether the settings are pulled or cloned is decided solely by the state of {@link IdeContext#getSettingsPath() IDE_HOME/settings} so that + * {@code ide create} and {@code ide update} share the very same logic. + * + * @param settingsPath the path to the (code-)settings directory which the health check should be performed on. + * @return the {@link SettingsHealthCheckResult}. + */ + public SettingsHealthCheckResult checkSettings(Path settingsPath) { + + if (settingsPath != null && !fileAccess.isEmptyDir(settingsPath)) { + // for a combined code and settings repository IDE_HOME/settings is a symlink into the code repository whose '.git' folder is one level above, + // so isGitRepo would report it as broken settings + return checkSettingsPresent(settingsPath); + } + return cloneAndCheckSettings(settingsPath); + } + + /** + * Health check for settings that are already present. As the project keeps working with these settings, a failure is only fatal if the user explicitly + * aborted. Here, if a new version is available, we clone the new version into a temporary folder and perform health checks. If the cloned, new version is + * valid, we call git update in the existing settings folder. + */ + private SettingsHealthCheckResult checkSettingsPresent(Path settingsPath) { + + RepositoryType settingsRepoType = RepositoryType.ofSettingsPath(settingsPath, this.context); + try { + + //If cloned repo is not (code-)settings repo, let the user re-clone the settings. + if (!settingsRepoType.isValid()) { + return cloneAndCheckSettings(settingsPath); + } + + //Otherwise, (e.g. user overrides), return valid. + return SettingsHealthCheckResult.ofSuccess(settingsRepoType, settingsPath, true); + } catch (RuntimeException e) { + cleanup(); + if (e instanceof CliAbortException) { + // the user answered "no" so we must not silently carry on + return SettingsHealthCheckResult.ofFailed(settingsRepoType, "Settings update aborted by end-user", settingsPath, true); + } + return SettingsHealthCheckResult.ofFailed(settingsRepoType, e.getMessage(), settingsPath, true); + } + } + + /** + * Health check for missing or broken settings (e.g. {@code ide create}). Without valid settings there is nothing to continue with, so every failure is fatal + * here. + */ + private SettingsHealthCheckResult cloneAndCheckSettings(Path settingsPath) { + + try { + if (settingsPath != null) { + backupBrokenSettings(settingsPath); + } + GitUrl gitUrl = getOrAskSettingsUrl(); + + Path tempCloneDir = cloneRepoToTempDir(gitUrl); + RepositoryType repositoryType = RepositoryType.ofGitRoot(tempCloneDir, this.context); + + if (!repositoryType.isValid()) { + + return SettingsHealthCheckResult.ofFailed(repositoryType, MESSAGE_INVALID_REPOSITORY, tempCloneDir, false); + } + return SettingsHealthCheckResult.ofSuccess(repositoryType, tempCloneDir, false); + } catch (RuntimeException e) { + cleanup(); + throw createGuaranteedFatalException(e); + } + } + + /** + * @param error the {@link RuntimeException} that made the settings setup fail. + * @return a {@link CliFatalException} that aborts the entire process. An existing {@link CliException} keeps its message and + * {@link CliException#getExitCode() exit code} so that e.g. an abort by the user is still reported as such. + */ + private static CliFatalException createGuaranteedFatalException(RuntimeException error) { + + if (error instanceof CliFatalException rethrow) { + return rethrow; + } else if (error instanceof CliException) { + return new CliFatalException("Error occurred during settings update: " + error.getMessage(), error); + } + return new CliFatalException("Error occurred during settings update: " + error.getClass() + ": " + error.getMessage(), error); + } + + private void pullSettingsAndSaveCommitId(Path settingsPath) { + + GitContext gitContext = this.context.getGitContext(); + if (gitContext.hasUntrackedFiles(settingsPath)) { + gitContext.pullSafelyWithStash(settingsPath); + } else { + gitContext.pull(settingsPath); + } + gitContext.saveCurrentCommitId(settingsPath, this.context.getSettingsCommitIdPath()); + } + + /** + * Clone a settings repository into a temporary directory. + * + * @param gitUrl {@link GitUrl} of the (code-)settings repository. + * @return {@link Path} of the temporary directory. + */ + private Path cloneRepoToTempDir(GitUrl gitUrl) { + + this.gitProjectName = gitUrl.getProjectName(); + + // createTempDir guarantees a unique and empty directory so no leftovers of a previous attempt can interfere and we can clone directly + this.tempRepoDir = this.context.getFileAccess().createTempDir("project-" + this.gitProjectName); + this.context.getGitContext().clone(gitUrl, this.tempRepoDir); + return this.tempRepoDir; + } + + private void backupBrokenSettings(Path settingsPath) { + + if ((settingsPath == null) || !Files.exists(settingsPath)) { + return; + } + + if (!fileAccess.isEmptyDir(settingsPath)) { + this.context.askToContinue(""" + Your settings repository seems to be broken ('.git' folder not present). + We can fix this by moving your settings to the backup. + You will be asked for the settings git URL and your settings will be cloned from scratch. + Do you want to proceed?"""); + } + fileAccess.backup(settingsPath); + } + + /** + * Applies the result of the {@link #checkSettings(Path)} health check by either pulling the settings in place or moving the verified clone to its final + * location. + * + * @param healthCheckResult {@link SettingsHealthCheckResult} health check result to use for applying the settings + * @return a {@link SettingsUpdateResult} representing the state, whether moving/pulling the newest settings was successful. + */ + public SettingsUpdateResult applySettings(SettingsHealthCheckResult healthCheckResult) { + + Path sourcePath = healthCheckResult.settingsDirectory(); + Path settingsPath = this.context.getSettingsPath(); + + RepositoryType repositoryType = RepositoryType.ofGitRoot(sourcePath, context); + + boolean onlyPull = healthCheckResult.repositoryType().isValid() && healthCheckResult.isExistingProject(); + + // Case 1: We performed "ide update"; so settings already existed and we just need to perform a git pull in the existing repo. + if (onlyPull) { + pullSettingsAndSaveCommitId(settingsPath); + + repositoryType = RepositoryType.ofSettingsPath(context.getSettingsPath(), this.context); + if (repositoryType != RepositoryType.SETTINGS) { + return new SettingsUpdateResult(SettingsUpdateStatus.SETTINGS_UPDATE_FAILED, + repositoryType, + "Update done, but the updated settings repository seems to be of an invalid type: " + repositoryType); + } + + return new SettingsUpdateResult(SettingsUpdateStatus.SETTINGS_UPDATED, repositoryType, null); + } + + String errorMessage = null; + Path gitRootDir = settingsPath; + SettingsUpdateStatus resultStatus = SettingsUpdateStatus.SETTINGS_UPDATE_FAILED; + + // Case 2: We freshly cloned the settings repo and need to move it to a target directory. + switch (repositoryType) { + case UNKNOWN -> errorMessage = "Cannot apply settings as type of the settings repo is incorrect"; + case SETTINGS -> { + + //move to IDE_HOME/SETTINGS + moveProject(sourcePath, settingsPath); + + resultStatus = SettingsUpdateStatus.SETTINGS_CLONED; + } + case CODE_SETTINGS_COMBINED -> { + + //this is a special case - here we need to symlink from IDE_HOME/settings to IDE_HOME/workspaces/main/repo_name/settings. + // (Formerly managed by the obsolete "--code" flag) + Path repoMoveTargetDirectory = this.context.getWorkspacePath().resolve(gitProjectName); + Path symlinkPath = this.context.getIdeHome().resolve(IdeContext.FOLDER_SETTINGS); + Path repoSettingsDirectory = repoMoveTargetDirectory.resolve(IdeContext.FOLDER_SETTINGS); + + moveProject(sourcePath, repoMoveTargetDirectory); + + context.getFileAccess().symlink(repoSettingsDirectory, symlinkPath); + + gitRootDir = repoMoveTargetDirectory; + resultStatus = SettingsUpdateStatus.SETTINGS_CLONED; + } + default -> errorMessage = "Unknown error during settings"; + } + + if (errorMessage == null) { + this.context.getGitContext().saveCurrentCommitId(gitRootDir, this.context.getSettingsCommitIdPath()); + } + return new SettingsUpdateResult(resultStatus, repositoryType, errorMessage); + } + + /** + * Removes the temporary clone. It is deleted and not backed up since it only contains a fresh clone without any user data and a backup would be created + * inside {@link IdeContext#getIdeHome() IDE_HOME} that may not even exist yet. Failures are only logged so that the actual error never gets masked. + */ + public void cleanup() { + + if (this.tempRepoDir == null) { + return; + } + try { + this.context.getFileAccess().delete(this.tempRepoDir); + } catch (RuntimeException e) { + LOG.warn("Failed to delete temporary directory {}", this.tempRepoDir, e); + } + this.tempRepoDir = null; + } + + private GitUrl getOrAskSettingsUrl() { + + String repository = handleDefaultRepository(this.settingsRepoProperty.getValue()); + GitUrl gitUrl = null; + if (repository != null) { + gitUrl = GitUrl.of(repository); + } + if ((gitUrl == null) || !gitUrl.isValid()) { + LOG.info(MESSAGE_SETTINGS_REPO_URL, this.context.getSettingsPath()); + } + String userPrompt = "Settings URL [" + IdeContext.DEFAULT_SETTINGS_REPO_URL + "]:"; + while ((gitUrl == null) || !gitUrl.isValid()) { + repository = handleDefaultRepository(this.context.askForInput(userPrompt, IdeContext.DEFAULT_SETTINGS_REPO_URL)); + gitUrl = GitUrl.of(repository); + if (!gitUrl.isValid()) { + LOG.warn("The input URL is not valid, please try again."); + } + } + return gitUrl; + } + + private String handleDefaultRepository(String repository) { + + if ("-".equals(repository)) { + LOG.info("'-' was found for the repository, the default settings repository '{}' will be used.", IdeContext.DEFAULT_SETTINGS_REPO_URL); + repository = IdeContext.DEFAULT_SETTINGS_REPO_URL; + } + return repository; + } + + private void moveProject(Path from, Path to) { + + try { + this.context.getFileAccess().move(from, to); + } catch (RuntimeException e) { + // FileAccess already reports source, target and the Windows file-lock hint so we only escalate to a fatal error here + throw new CliFatalException(e.getMessage(), e); + } + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java index 7e3632cb..4f514f65 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java @@ -37,8 +37,8 @@ import com.devonfw.tools.ide.commandlet.CommandletManagerImpl; import com.devonfw.tools.ide.commandlet.ContextCommandlet; import com.devonfw.tools.ide.commandlet.EnvironmentCommandlet; -import com.devonfw.tools.ide.commandlet.UpdateCommandlet; import com.devonfw.tools.ide.commandlet.UpgradeCommandlet; +import com.devonfw.tools.ide.commandlet.update.UpdateCommandlet; import com.devonfw.tools.ide.common.SystemPath; import com.devonfw.tools.ide.completion.CompletionCandidate; import com.devonfw.tools.ide.completion.CompletionCandidateCollector; @@ -50,6 +50,7 @@ import com.devonfw.tools.ide.git.GitContext; import com.devonfw.tools.ide.git.GitContextImpl; import com.devonfw.tools.ide.git.GitUrl; +import com.devonfw.tools.ide.git.repository.RepositoryType; import com.devonfw.tools.ide.io.FileAccess; import com.devonfw.tools.ide.io.FileAccessImpl; import com.devonfw.tools.ide.log.IdeLogArgFormatter; @@ -699,31 +700,15 @@ public Path getSettingsPath() { public Path getSettingsGitRepository() { Path settingsPath = getSettingsPath(); + RepositoryType settingsRepositoryType = RepositoryType.ofSettingsPath(settingsPath, this); // check whether the settings path has a .git folder only if its not a symbolic link or junction - if ((settingsPath != null) && !Files.exists(settingsPath.resolve(".git")) && !isSettingsCodeRepository()) { + if ((settingsPath != null) && !Files.exists(settingsPath.resolve(".git")) && !(settingsRepositoryType == RepositoryType.CODE_SETTINGS_COMBINED)) { LOG.error("Settings repository exists but is not a git repository."); return null; } return settingsPath; } - @Override - public boolean isSettingsCodeRepository() { - - Path settingsPath = getSettingsPath(); - if (settingsPath != null) { - boolean settingsIsLink = Files.isSymbolicLink(settingsPath) || getFileAccess().isJunction(settingsPath); - if (settingsIsLink) { - Path realPath = getFileAccess().toRealPath(this.settingsPath); - if (realPath != null) { - return getGitContext().isGitRepo(realPath.getParent()); - } - return true; - } - } - return false; - } - @Override public Path getSettingsCommitIdPath() { @@ -1143,8 +1128,8 @@ public String askForSecret(String message, String defaultValue) { * @param message the question to ask. * @param defaultValue the value to return if the user accepts the default (by entering an empty value) or {@code null} to re-ask until a value is * entered. - * @param secret - {@code true} to read the input in a masked way (see {@link #readSecretLine()}) and to mask it in the log output, {@code false} to - * read it as plain text. + * @param secret - {@code true} to read the input in a masked way (see {@link #readSecretLine()}) and to mask it in the log output, {@code false} to read + * it as plain text. * @return the entered value or the default value. */ private String ask(String message, String defaultValue, boolean secret) { @@ -1564,7 +1549,8 @@ settingsRepository, getSettingsCommitIdPath()))) { */ private String determineSettingsUpdateMessage(Commandlet cmd) { boolean update = cmd instanceof UpdateCommandlet; - if (isSettingsCodeRepository()) { + RepositoryType settingsRepositoryType = RepositoryType.ofSettingsPath(getSettingsPath(), this); + if (settingsRepositoryType == RepositoryType.CODE_SETTINGS_COMBINED) { if (update && (isForceMode() || isForcePull())) { return null; } diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java index b141c847..34f142c7 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java @@ -11,6 +11,7 @@ import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.cli.CliOfflineException; import com.devonfw.tools.ide.commandlet.CommandletManager; +import com.devonfw.tools.ide.commandlet.update.AbstractUpdateCommandlet; import com.devonfw.tools.ide.common.SystemPath; import com.devonfw.tools.ide.environment.EnvironmentVariables; import com.devonfw.tools.ide.environment.EnvironmentVariablesType; @@ -69,7 +70,7 @@ public interface IdeContext extends IdeStartContext { /** * The default settings URL. * - * @see com.devonfw.tools.ide.commandlet.AbstractUpdateCommandlet + * @see AbstractUpdateCommandlet */ String DEFAULT_SETTINGS_REPO_URL = "https://github.com/devonfw/ide-settings.git"; @@ -309,7 +310,8 @@ default String askForSecret(String message) { void addSecretVariable(String name); /** - * Registers the value of a variable as secret if the variable was marked via {@link #addSecretVariable(String)}. Has to be called before the value is logged. + * Registers the value of a variable as secret if the variable was marked via {@link #addSecretVariable(String)}. Has to be called before the value is + * logged. * * @param name the name of the variable. * @param value the value of the variable. @@ -629,11 +631,6 @@ default Path getRepositoriesPath() { */ Path getSettingsGitRepository(); - /** - * @return {@code true} if the settings repository is a symlink or a junction to a code-repository. - */ - boolean isSettingsCodeRepository(); - /** * @return the {@link Path} to the file containing the last tracked commit Id of the settings repository. */ diff --git a/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryType.java b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryType.java new file mode 100644 index 00000000..7268317e --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryType.java @@ -0,0 +1,98 @@ +package com.devonfw.tools.ide.git.repository; + +import java.nio.file.Files; +import java.nio.file.Path; + +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.environment.EnvironmentVariables; +import com.devonfw.tools.ide.io.FileAccess; + +/** + * Enum representation of a detected {@link RepositoryType}. + */ +public enum RepositoryType { + + /** Git Repository is a settings repository. */ + SETTINGS, + + /** A combined code & settings repository contains both the settings-folder and the code within the workspace folder. */ + CODE_SETTINGS_COMBINED, + + /** The type of the repository could not be determined. */ + UNKNOWN; + + /** + * Checks whether the given git repository is a settings repository, a combined settings and code repository, or a typical code repository. A combined code + * and settings repository is detected by a top-level {@code settings} folder that itself is a valid settings folder. + * + * @param repositoryPath the {@link Path} to the repository to check. + * @param ideContext a {@link IdeContext} + * @return the {@link RepositoryType} of the repository. + */ + public static RepositoryType ofGitRoot(Path repositoryPath, IdeContext ideContext) { + + if (repositoryPath == null || !Files.isDirectory(repositoryPath)) { + return RepositoryType.UNKNOWN; + } + if (isSettingsFolder(repositoryPath) && ideContext.getGitContext().isGitRepo(repositoryPath)) { + return RepositoryType.SETTINGS; + } + Path settingsFolder = repositoryPath.resolve(IdeContext.FOLDER_SETTINGS); + if (isSettingsFolder(settingsFolder)) { + return RepositoryType.CODE_SETTINGS_COMBINED; + } + // there is no valid settings folder to be found. + return RepositoryType.UNKNOWN; + } + + /** + * @param settingsPath + * @param context + * @return + */ + public static RepositoryType ofSettingsPath(Path settingsPath, IdeContext context) { + + if (settingsPath == null || context == null) { + return RepositoryType.UNKNOWN; + } + if (context.getGitContext().isGitRepo(settingsPath)) { + return RepositoryType.SETTINGS; + } else if (isCombinedSettingsCodeRepository(settingsPath, context)) { + return RepositoryType.CODE_SETTINGS_COMBINED; + } + return RepositoryType.UNKNOWN; + } + + /** + * @return true if repository is either of type {@code SETTINGS} or {@code CODE_SETTINGS_COMBINED} + */ + public boolean isValid() { + return this == SETTINGS || this == CODE_SETTINGS_COMBINED; + } + + /** + * @param folder the {@link Path} to check. + * @return {@code true} if the given {@code folder} is the root of a settings repository, {@code false} otherwise. + */ + private static boolean isSettingsFolder(Path folder) { + + return (Files.exists(folder.resolve(EnvironmentVariables.DEFAULT_PROPERTIES)) + || Files.exists(folder.resolve(EnvironmentVariables.LEGACY_PROPERTIES))); + } + + private static boolean isCombinedSettingsCodeRepository(Path settingsPath, IdeContext context) { + + FileAccess fileAccess = context.getFileAccess(); + if (settingsPath != null) { + boolean settingsIsLink = Files.isSymbolicLink(settingsPath) || fileAccess.isJunction(settingsPath); + if (settingsIsLink) { + Path realPath = fileAccess.toRealPath(settingsPath); + if (realPath != null) { + return context.getGitContext().isGitRepo(realPath.getParent()); + } + return true; + } + } + return false; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/step/Step.java b/cli/src/main/java/com/devonfw/tools/ide/step/Step.java index 19760d97..800caa95 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/step/Step.java +++ b/cli/src/main/java/com/devonfw/tools/ide/step/Step.java @@ -3,6 +3,8 @@ import java.util.concurrent.Callable; import java.util.function.Supplier; +import com.devonfw.tools.ide.cli.CliException; + /** * Interface for a {@link Step} of the process. Allows to split larger processes into smaller steps that are traced and measured. Also prevents that if one step * fails, the overall process can still continue so a sub-step (e.g. "plugin installation" or "git update") does not automatically block the entire process. At @@ -219,7 +221,8 @@ default boolean run(Runnable stepCode) { /** * @param stepCode the {@link Runnable} to {@link Runnable#run() execute} for this {@link Step}. - * @param rethrow - {@code true} to rethrow a potential {@link Throwable error}. + * @param rethrow - {@code true} to rethrow a potential {@link Throwable error}. Independent of this flag an error is always rethrown if it + * {@link CliException#isForceRethrowInStep() forces} it. * @return {@code true} on success, {@code false} on error (if {@code rethrow} is {@code false}). */ default boolean run(Runnable stepCode, boolean rethrow) { @@ -231,8 +234,10 @@ default boolean run(Runnable stepCode, boolean rethrow) { } return true; } catch (RuntimeException | Error e) { - error(e); - if (rethrow) { + boolean forceRethrow = isForceRethrow(e); + // if the error is rethrown it gets logged by the caller so we suppress duplicated error messages here + error(e, forceRethrow); + if (rethrow || forceRethrow) { throw e; } return false; @@ -264,7 +269,8 @@ default R call(Callable stepCode, Supplier resultOnErrorSupplier) { /** * @param stepCode the {@link Callable} to {@link Callable#call() execute} for this {@link Step}. - * @param rethrow - {@code true} to rethrow a potential {@link Throwable error}. + * @param rethrow - {@code true} to rethrow a potential {@link Throwable error}. Independent of this flag an error is always rethrown if it + * {@link CliException#isForceRethrowInStep() forces} it. * @param resultOnErrorSupplier the {@link Supplier} {@link Supplier#get() providing} the result to be returned in case of a {@link Throwable error}. * @param type of the return value. * @return the value returned from {@link Callable#call()}. @@ -278,8 +284,10 @@ default R call(Callable stepCode, boolean rethrow, Supplier resultOnEr } return result; } catch (Throwable e) { - error(e); - if (rethrow) { + boolean forceRethrow = isForceRethrow(e); + // if the error is rethrown it gets logged by the caller so we suppress duplicated error messages here + error(e, forceRethrow); + if (rethrow || forceRethrow) { if (e instanceof RuntimeException re) { throw re; } else if (e instanceof Error error) { @@ -294,4 +302,13 @@ default R call(Callable stepCode, boolean rethrow, Supplier resultOnEr } } + /** + * @param error the {@link Throwable} that occurred inside a {@link Step}. + * @return {@code true} if the given {@code error} has to be rethrown even if the {@link Step} was not asked to rethrow errors, {@code false} otherwise. + */ + private static boolean isForceRethrow(Throwable error) { + + return (error instanceof CliException cliException) && cliException.isForceRethrowInStep(); + } + } diff --git a/cli/src/main/resources/nls/Help.properties b/cli/src/main/resources/nls/Help.properties index e678805c..0f9d3dab 100644 --- a/cli/src/main/resources/nls/Help.properties +++ b/cli/src/main/resources/nls/Help.properties @@ -187,7 +187,6 @@ cmd.yarn.detail=Yarn is a package manager and build tool for JavaScript. Detaile commandlets=Available commandlets: icd-hint=Hint: Use 'icd' command to easily navigate between your IDE home, projects, and workspaces. Type 'icd --help' for more details. opt.--batch=enable batch mode (non-interactive). -opt.--code=clone given code repository containing a settings folder into workspaces so that settings can be committed alongside code changes. opt.--debug=enable debug logging. opt.--force=enable force mode. opt.--force-plugin-reinstall=resets installed plugins to the project configuration diff --git a/cli/src/main/resources/nls/Help_de.properties b/cli/src/main/resources/nls/Help_de.properties index 8276ff9b..3413367b 100644 --- a/cli/src/main/resources/nls/Help_de.properties +++ b/cli/src/main/resources/nls/Help_de.properties @@ -187,7 +187,6 @@ cmd.yarn.detail=Yarn ist ein Package Manager und Build-Werkzeug für JavaScript. commandlets=Verfügbare Kommandos: icd-hint=Hinweis: Verwenden Sie den Befehl 'icd' um einfach zwischen Ihrem IDE-Hauptverzeichnis, Projekten und Workspaces zu navigieren. Geben Sie 'icd --help' für weitere Details ein. opt.--batch=Aktiviert den Batch-Modus (nicht-interaktive Stapelverarbeitung). -opt.--code=Git-Repository sowohl als Code- als auch als Settings-Repository verwenden. opt.--debug=Aktiviert Debug-Ausgaben (Fehleranalyse). opt.--force=Aktiviert den Force-Modus (Erzwingen). opt.--force-plugin-reinstall=Setzt installierte Plugins zurück auf die Projektkonfiguration. diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/CreateCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/CreateCommandletTest.java index 98534e72..683b986c 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/CreateCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/CreateCommandletTest.java @@ -7,18 +7,16 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; import com.devonfw.tools.ide.cli.CliArguments; import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.context.IdeTestContext; -import com.devonfw.tools.ide.context.ProcessContextGitMock; import com.devonfw.tools.ide.environment.EnvironmentVariables; import com.devonfw.tools.ide.environment.EnvironmentVariablesType; import com.devonfw.tools.ide.git.GitContextImplMock; +import com.devonfw.tools.ide.io.WindowsSymlinkTestHelper; import com.devonfw.tools.ide.version.IdeVersion; /** @@ -67,54 +65,9 @@ void testCreateCommandletRun() { assertThat(newProjectPath.resolve(IdeContext.FOLDER_PLUGINS)).exists(); assertThat(newProjectPath.resolve(IdeContext.FOLDER_SOFTWARE)).exists(); assertThat(newProjectPath.resolve(IdeContext.FOLDER_WORKSPACES).resolve(IdeContext.WORKSPACE_MAIN)).exists(); - } - - @ParameterizedTest - @ValueSource(strings = { "https://some-code-repository", "ssh://some-settings-repository" }) - void testWarningWhenRepoDoesNotMeetNamingConvention(String invalidRepo, @TempDir Path tempDir) { - // arrange - ProcessContextGitMock gitMock = new ProcessContextGitMock(context, tempDir); - context.setProcessContext(gitMock); - CreateCommandlet cc = context.getCommandletManager().getCommandlet(CreateCommandlet.class); - cc.newProject.setValueAsString(NEW_PROJECT_NAME, context); - cc.codeRepositoryFlag.setValue(!invalidRepo.contains("code")); // raise conflict - cc.settingsRepo.setValue(invalidRepo); - cc.skipTools.setValue(true); - context.setAnswers("yes"); - // act - cc.run(); - // assert - assertThat(context).logAtInteraction().hasMessageContaining("Do you really want to create the project?"); - Path newProjectPath = context.getIdeRoot().resolve(NEW_PROJECT_NAME); - assertThat(newProjectPath).exists(); - assertThat(context.getIdeHome()).isEqualTo(newProjectPath); - assertThat(newProjectPath.resolve(IdeContext.FOLDER_PLUGINS)).exists(); - assertThat(newProjectPath.resolve(IdeContext.FOLDER_SOFTWARE)).exists(); - assertThat(newProjectPath.resolve(IdeContext.FOLDER_WORKSPACES).resolve(IdeContext.WORKSPACE_MAIN)).exists(); - } - - @Test - void testWarningWhenCodeRepoUsingDefaultMark(@TempDir Path tempDir) { - String invalidCodeRepo = "-"; - // arrange - ProcessContextGitMock gitMock = new ProcessContextGitMock(context, tempDir); - context.setProcessContext(gitMock); - CreateCommandlet cc = context.getCommandletManager().getCommandlet(CreateCommandlet.class); - cc.newProject.setValueAsString(NEW_PROJECT_NAME, context); - cc.settingsRepo.setValue(invalidCodeRepo); - cc.codeRepositoryFlag.setValue(true); - cc.skipTools.setValue(true); - context.setAnswers("https://some-code-repository"); - // act - cc.run(); - // assert - assertThat(context).logAtWarning().hasMessageContaining("'-' is found after '--code'. This is invalid."); - Path newProjectPath = context.getIdeRoot().resolve(NEW_PROJECT_NAME); - assertThat(newProjectPath).exists(); - assertThat(context.getIdeHome()).isEqualTo(newProjectPath); - assertThat(newProjectPath.resolve(IdeContext.FOLDER_PLUGINS)).exists(); - assertThat(newProjectPath.resolve(IdeContext.FOLDER_SOFTWARE)).exists(); - assertThat(newProjectPath.resolve(IdeContext.FOLDER_WORKSPACES).resolve(IdeContext.WORKSPACE_MAIN)).exists(); + // the settings have to be cloned into the new project and not into the project the create command was started from + assertThat(newProjectPath.resolve(IdeContext.FOLDER_SETTINGS).resolve("ide.properties")).exists(); + assertThat(context.getIdeRoot().resolve("_ide/tmp/projects").resolve(NEW_PROJECT_NAME)).doesNotExist(); } @Test @@ -220,6 +173,54 @@ void testWelcomeMessageDisplayed() { assertThat(context).logAtInfo().hasMessageContaining("Welcome to your new IDEasy project!"); } + @Test + void testProjectWithInvalidRepositoryNotCreated() { + + // arrange - create a new project that is invalid (does not contain ide.properties file) + GitContextImplMock gitContextImplMock = new GitContextImplMock(context, TEST_RESOURCES.resolve("pypi")); + + context.setGitContext(gitContextImplMock); + CreateCommandlet cc = context.getCommandletManager().getCommandlet(CreateCommandlet.class); + cc.newProject.setValueAsString(NEW_PROJECT_NAME, context); + cc.settingsRepo.setValue(IdeContext.DEFAULT_SETTINGS_REPO_URL); + cc.skipTools.setValue(true); + + // act - run the create command + assertThatThrownBy(cc::run) + .isInstanceOf(CliException.class) + .hasMessageContaining( + "Fatal error while cloning settings: The settings health check failed: " + + "The given git repository URL does not point to a valid settings or code-settings repository. Please verify and try again."); + + // assert - if "ide create" fails then no project shall be created at all + assertThat(context.getIdeRoot().resolve(NEW_PROJECT_NAME)).doesNotExist(); + assertThat(context.getTempPath().resolve(IdeContext.FOLDER_PROJECTS).resolve(NEW_PROJECT_NAME)).doesNotExist(); + } + + @Test + void testCreateWithCodeSettingsRepository() { + + // arrange - a combined code and settings repository has the settings in a top-level "settings" folder + WindowsSymlinkTestHelper.assumeSymlinksSupported(); + GitContextImplMock gitContextImplMock = new GitContextImplMock(context, TEST_RESOURCES.resolve("code-settings")); + context.setGitContext(gitContextImplMock); + CreateCommandlet cc = context.getCommandletManager().getCommandlet(CreateCommandlet.class); + cc.newProject.setValueAsString(NEW_PROJECT_NAME, context); + cc.settingsRepo.setValue("https://github.com/devonfw/code-settings-repo.git"); + cc.skipTools.setValue(true); + + // act + cc.run(); + + // assert - the repository is placed into the workspace and IDE_HOME/settings is a symlink to its settings folder + Path newProjectPath = context.getIdeRoot().resolve(NEW_PROJECT_NAME); + Path codePath = newProjectPath.resolve(IdeContext.FOLDER_WORKSPACES).resolve(IdeContext.WORKSPACE_MAIN).resolve("code-settings-repo"); + assertThat(codePath.resolve(IdeContext.FOLDER_SETTINGS).resolve("ide.properties")).exists(); + Path settingsLink = newProjectPath.resolve(IdeContext.FOLDER_SETTINGS); + assertThat(settingsLink).isSymbolicLink(); + assertThat(settingsLink.resolve("ide.properties")).exists(); + } + @Test void testCreateWithDashPlaceholderAsCliArgument() { // arrange - see https://github.com/devonfw/IDEasy/issues/2106 @@ -234,7 +235,7 @@ void testCreateWithDashPlaceholderAsCliArgument() { assertThat(result).isEqualTo(0); assertThat(context).logAtError().hasNoMessageContaining("not found for commandlet"); assertThat(context).logAtInfo() - .hasMessageContaining("'-' was found for settings repository, the default settings repository"); + .hasMessageContaining("'-' was found for the repository, the default settings repository"); Path newProjectPath = context.getIdeRoot().resolve(NEW_PROJECT_NAME); assertThat(newProjectPath).exists(); } diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/UpdateCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/UpdateCommandletTest.java index 626d0b0a..a1efb803 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/UpdateCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/UpdateCommandletTest.java @@ -7,11 +7,14 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import com.devonfw.tools.ide.commandlet.update.UpdateCommandlet; import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.context.IdeTestContext; import com.devonfw.tools.ide.environment.EnvironmentVariables; import com.devonfw.tools.ide.environment.EnvironmentVariablesType; +import com.devonfw.tools.ide.git.GitContext; +import com.devonfw.tools.ide.git.GitContextMock; import com.devonfw.tools.ide.tool.java.Java; import com.devonfw.tools.ide.tool.mvn.Mvn; import com.devonfw.tools.ide.variable.IdeVariables; @@ -25,7 +28,7 @@ class UpdateCommandletTest extends AbstractIdeContextTest { private static final String PROJECT_UPDATE = "update"; - private static final String SUCCESS_UPDATE_SETTINGS = "Successfully ended step 'update (pull) settings repository'."; + private static final String SUCCESS_UPDATE_SETTINGS = "Successfully ended step 'Update settings repository'."; private static final String SUCCESS_INSTALL_OR_UPDATE_SOFTWARE = "Install or update software"; @Test @@ -155,4 +158,57 @@ void testRunUpdateSoftwareDoesNotFailWhenSettingsPathIsDeleted(WireMockRuntimeIn assertThat(context).logAtSuccess().hasMessage(SUCCESS_UPDATE_SETTINGS); assertThat(context).logAtSuccess().hasMessageContaining(SUCCESS_INSTALL_OR_UPDATE_SOFTWARE); } + + /** + * Tests that a settings folder that exists but is not a git repository is backed up and cloned from scratch after the user confirmed. + */ + @Test + void testRunUpdateWithBrokenSettingsFolder() { + + // arrange + IdeTestContext context = newContext(PROJECT_UPDATE); + Path settingsPath = context.getSettingsPath(); + // remove the '.git' folder so the settings are present but broken + context.getFileAccess().delete(settingsPath.resolve(GitContext.GIT_FOLDER)); + UpdateCommandlet update = context.getCommandletManager().getCommandlet(UpdateCommandlet.class); + // first answer confirms the backup of the broken settings, second answer picks the default settings repository + context.setAnswers("yes", "-"); + + // act + update.run(); + + // assert + assertThat(context).logAtSuccess().hasMessage(SUCCESS_UPDATE_SETTINGS); + assertThat(context.getIdeHome().resolve(IdeContext.FOLDER_BACKUPS)).exists(); + assertThat(settingsPath.resolve(GitContext.GIT_FOLDER)).exists(); + assertThat(context).logAtSuccess().hasMessageContaining(SUCCESS_INSTALL_OR_UPDATE_SOFTWARE); + } + + /** + * Tests that a failing "git pull" (e.g. due to an error of a custom git server) only fails the settings step while the software is still installed. + *

+ * See: #2335 for reference. + */ + @Test + void testRunUpdateContinuesWhenPullFails() { + + // arrange + IdeTestContext context = newContext(PROJECT_UPDATE); + context.setGitContext(new GitContextMock(context) { + @Override + public void pull(Path repository) { + + throw new IllegalStateException("git pull failed due to an error of the custom git server"); + } + }); + UpdateCommandlet update = context.getCommandletManager().getCommandlet(UpdateCommandlet.class); + + // act + update.run(); + + // assert + assertThat(context).logAtError().hasMessage("Step 'Applying settings' ended with failure."); + assertThat(context).log().hasNoMessage(SUCCESS_UPDATE_SETTINGS); + assertThat(context).logAtSuccess().hasMessageContaining(SUCCESS_INSTALL_OR_UPDATE_SOFTWARE); + } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMock.java b/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMock.java index d794f0ad..c2069af4 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMock.java +++ b/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMock.java @@ -20,7 +20,11 @@ */ public class GitContextMock extends GitContextImpl { - private static final String MOCKED_URL_VALUE = "mocked url value"; + /** + * Fallback URL for repositories without a mocked {@code .git/config} - has to be a valid git URL that is not the default settings URL, so both the settings + * health check and tool settings substitution (e.g. Maven) run succesfully (they check URL validity). + */ + private static final String MOCKED_URL_VALUE = "https://github.com/devonfw/mocked-settings.git"; /** Filename used to persist mocked remotes inside the {@code .git} folder. */ private static final String REMOTES_FILE = "remotes.properties"; @@ -57,6 +61,9 @@ public void clone(GitUrl gitUrl, Path repository) { FileAccess fileAccess = this.context.getFileAccess(); fileAccess.mkdirs(repository); + // Create ide.properties to simulate a valid repository + fileAccess.touch(repository.resolve("ide.properties")); + Path gitFolder = repository.resolve(GIT_FOLDER); fileAccess.mkdirs(gitFolder); String branch = gitUrl.branch(); @@ -260,7 +267,7 @@ public String determineRemote(Path repository) { return DEFAULT_REMOTE; } -/** + /** * Adds pending commits to simulate remote changes. Commits are stored per repository and applied on pull. * * @param repository the repository the commits belong to diff --git a/cli/src/test/java/com/devonfw/tools/ide/step/StepTest.java b/cli/src/test/java/com/devonfw/tools/ide/step/StepTest.java index 0a5b87d1..0df53ab1 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/step/StepTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/step/StepTest.java @@ -1,7 +1,10 @@ package com.devonfw.tools.ide.step; +import java.util.concurrent.atomic.AtomicBoolean; + import org.junit.jupiter.api.Test; +import com.devonfw.tools.ide.cli.CliFatalException; import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeTestContext; import com.devonfw.tools.ide.log.IdeLogEntry; @@ -130,4 +133,66 @@ void testInvalidUsageErrorSuccess() { IdeLogEntry.ofDebug("Step 'Test-Step' ended successfully.")); } + @Test + void testRunSwallowsRegularError() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, "project", false); + Step step = context.newStep("Test-Step"); + // act + boolean success = step.run(() -> { + throw new IllegalStateException("regular error"); + }); + // assert + assertThat(success).isFalse(); + assertThat(step.isFailure()).isTrue(); + } + + @Test + void testRunRethrowsForcedError() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, "project", false); + Step step = context.newStep("Test-Step"); + // act & assert + assertThatThrownBy(() -> step.run(() -> { + throw new CliFatalException("fatal error"); + })).isInstanceOf(CliFatalException.class).hasMessage("fatal error"); + assertThat(step.isFailure()).isTrue(); + } + + @Test + void testCallReturnsFallbackOnRegularError() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, "project", false); + Step step = context.newStep("Test-Step"); + // act + String result = step.call(() -> { + throw new IllegalStateException("regular error"); + }, () -> "fallback"); + // assert + assertThat(result).isEqualTo("fallback"); + assertThat(step.isFailure()).isTrue(); + } + + @Test + void testCallRethrowsForcedError() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, "project", false); + Step step = context.newStep("Test-Step"); + AtomicBoolean fallbackUsed = new AtomicBoolean(false); + // act & assert + assertThatThrownBy(() -> step.call(() -> { + throw new CliFatalException("fatal error"); + }, () -> { + fallbackUsed.set(true); + return "fallback"; + })).isInstanceOf(CliFatalException.class).hasMessage("fatal error"); + assertThat(step.isFailure()).isTrue(); + // the error is rethrown, so the fallback must not be returned + assertThat(fallbackUsed).isFalse(); + } + } diff --git a/cli/src/test/resources/code-settings/settings/ide.properties b/cli/src/test/resources/code-settings/settings/ide.properties new file mode 100644 index 00000000..28913aee --- /dev/null +++ b/cli/src/test/resources/code-settings/settings/ide.properties @@ -0,0 +1 @@ +IDE_TOOLS=java,mvn diff --git a/cli/src/test/resources/settings/ide.properties b/cli/src/test/resources/settings/ide.properties new file mode 100644 index 00000000..e69de29b diff --git a/documentation/settings.adoc b/documentation/settings.adoc index e9de2335..cb619de0 100644 --- a/documentation/settings.adoc +++ b/documentation/settings.adoc @@ -18,17 +18,18 @@ This gives you the freedom to control and manage the tools with their versions a To setup and customize these settings simply follow the link:usage.adoc#admin[admin usage guide]. Then tell your team to create the project using your project sepcific settings git URL: ``` -ide create «project-name» --code «settings-url» +ide create «project-name» «settings-url» ``` == Code-repository It is even possible to include your settings into your code repository by having the `settings` folder directly on top-level of your code git repository. This allows you to keep settings changes in sync with code changes and manage them in the same pull/merge requests. -To use this approach simply copy the content of https://github.com/devonfw/ide-settings[ide-settings] to a top-level `settings` folder in your code repository root and tell your developers to create the project usining the `--code` option: +To use this approach simply copy the content of https://github.com/devonfw/ide-settings[ide-settings] to a top-level `settings` folder in your code repository root. +IDEasy will automatically recognize that you are using a code repository, therefore just use the same command as above: ``` -ide create «project-name» --code «code-repo-url» +ide create «project-name» «code-repo-url» ``` IDEasy will clone your repository and create a symlink to the settings folder. @@ -50,6 +51,18 @@ But we do not want to forget the following advantage: Anyhow you can still create feature branches in standalone settings repositories to manage such scenarios and follow KISS and trunk-based development so you more or less avoid such problems. However, if you are in a monolithic project with complex release branches you may consider using the "settings in code repository" approach. +== Health check + +Whenever `IDEasy` clones or updates your settings it first clones the git repository into a temporary directory and performs a health check on it: + +* the given git URL has to be valid, +* cloning the repository has to succeed, +* and the repository has to be a settings repository or a combined code and settings repository (see xref:code-repository[above]). + +Only if this health check succeeded the settings are installed: an existing settings repository is updated via `git pull` while a new one is moved from the temporary directory to its final location. +This way a broken or wrong git URL can never leave you with a damaged project. +In particular `ide create` will not create the project at all if the health check fails, so you can simply fix the URL and try again. + == Structure The settings folder has to follow this file structure: From 8641e859284c70e0f4e21e6e2e4f1d52299ddfc1 Mon Sep 17 00:00:00 2001 From: Paras14 <53565432+Paras14@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:46:00 +0200 Subject: [PATCH 06/66] #2103: Integrate Obsidian (#2380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jörg Hohwiller --- CHANGELOG.adoc | 1 + .../ide/commandlet/CommandletManagerImpl.java | 2 + .../tools/ide/tool/obsidian/Obsidian.java | 42 +++++++++ cli/src/main/resources/nls/Help.properties | 2 + cli/src/main/resources/nls/Help_de.properties | 2 + .../tools/ide/tool/obsidian/ObsidianTest.java | 91 +++++++++++++++++++ documentation/LICENSE.adoc | 1 + 7 files changed, 141 insertions(+) create mode 100644 cli/src/main/java/com/devonfw/tools/ide/tool/obsidian/Obsidian.java create mode 100644 cli/src/test/java/com/devonfw/tools/ide/tool/obsidian/ObsidianTest.java diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index d70b6006..1fcfb416 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -6,6 +6,7 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDE Release with new features and bugfixes: +* https://github.com/devonfw/IDEasy/issues/2103[#2103]: Integrate Obsidian * https://github.com/devonfw/IDEasy/issues/1695[#1695]: Project creation logic extended with health checks and removed `--code` flag. * https://github.com/devonfw/IDEasy/issues/2190[#2190]: IDEasy destroys my python installation * https://github.com/devonfw/IDEasy/issues/2178[#2178]: Make ReleaseCommandlet independent of specific build commandlet and fix `ide build` using npm instead of yarn diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java index 40e81f3b..2727e051 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java @@ -57,6 +57,7 @@ import com.devonfw.tools.ide.tool.ng.Ng; import com.devonfw.tools.ide.tool.node.Node; import com.devonfw.tools.ide.tool.npm.Npm; +import com.devonfw.tools.ide.tool.obsidian.Obsidian; import com.devonfw.tools.ide.tool.oc.Oc; import com.devonfw.tools.ide.tool.pgadmin.PgAdmin; import com.devonfw.tools.ide.tool.pip.Pip; @@ -194,6 +195,7 @@ public CommandletManagerImpl(IdeContext context) { add(new Just(context)); add(new SoapUi(context)); add(new Ruff(context)); + add(new Obsidian(context)); } /** diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/obsidian/Obsidian.java b/cli/src/main/java/com/devonfw/tools/ide/tool/obsidian/Obsidian.java new file mode 100644 index 00000000..fcbf0cb8 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/obsidian/Obsidian.java @@ -0,0 +1,42 @@ +package com.devonfw.tools.ide.tool.obsidian; + +import java.util.Set; + +import com.devonfw.tools.ide.common.Tag; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.tool.GlobalToolCommandlet; + +/** + * {@link GlobalToolCommandlet} for Obsidian. + */ +public class Obsidian extends GlobalToolCommandlet { + + /** + * The constructor. + * + * @param context the {@link IdeContext}. + */ + public Obsidian(IdeContext context) { + + super(context, "obsidian", Set.of(Tag.MARK_DOWN)); + } + + @Override + public boolean isExtract() { + + return switch (this.context.getSystemInfo().getOs()) { + // the Windows download is an installer executable that has to be started, not extracted + case WINDOWS -> false; + // the macOS download is a DMG image that has to be mounted so the *.app can be taken out of it + case MAC -> true; + // TODO: depends on which Linux artifact the UrlUpdater ends up publishing (see #2186) + case LINUX -> true; + }; + } + + @Override + public String getWindowsRegistryAppName() { + + return "Obsidian"; + } +} diff --git a/cli/src/main/resources/nls/Help.properties b/cli/src/main/resources/nls/Help.properties index 0f9d3dab..ba8b7c72 100644 --- a/cli/src/main/resources/nls/Help.properties +++ b/cli/src/main/resources/nls/Help.properties @@ -107,6 +107,8 @@ cmd.node=Tool commandlet for Node.js (JavaScript runtime). cmd.node.detail=Node.js is a JavaScript runtime environment for server-side applications. Detailed documentation can be found at https://nodejs.org/en/docs/ cmd.npm=Tool commandlet for Npm (JavaScript Node Package Manager). cmd.npm.detail=npm (Node Package Manager) is used for managing JavaScript packages. Detailed documentation can be found at https://docs.npmjs.com/ +cmd.obsidian=Tool commandlet for Obsidian. +cmd.obsidian.detail=Obsidian is a knowledge base and note-taking application that works on a local folder of plain Markdown files. Detailed documentation can be found at https://help.obsidian.md/ cmd.oc=Tool commandlet for Openshift CLI (Kubernetes management tool). cmd.oc.detail=OpenShift CLI (oc) is a command-line tool for managing OpenShift clusters. Detailed documentation can be found at https://docs.openshift.com/ cmd.pgadmin=Tool commandlet for pgAdmin. diff --git a/cli/src/main/resources/nls/Help_de.properties b/cli/src/main/resources/nls/Help_de.properties index 3413367b..07b31572 100644 --- a/cli/src/main/resources/nls/Help_de.properties +++ b/cli/src/main/resources/nls/Help_de.properties @@ -107,6 +107,8 @@ cmd.node=Werkzeug Kommando für Node.js (JavaScript Laufzeitumgebung). cmd.node.detail=Node.js ist eine JavaScript-Laufzeitumgebung für serverseitige Anwendungen. Detaillierte Dokumentation ist zu finden unter https://nodejs.org/en/docs/ cmd.npm=Werkzeug Kommando für Npm (JavaScript Node Package Manager). cmd.npm.detail=npm wird zur Verwaltung von JavaScript-Paketen verwendet. Detaillierte Dokumentation ist zu finden unter https://docs.npmjs.com/ +cmd.obsidian=Werkzeug Kommando für Obsidian. +cmd.obsidian.detail=Obsidian ist eine Wissensdatenbank und Notizanwendung, die auf einem lokalen Ordner mit einfachen Markdown-Dateien arbeitet. Detaillierte Dokumentation ist zu finden unter https://help.obsidian.md/ cmd.oc=Werkzeug Kommando für Openshift CLI (Kubernetes Management Tool). cmd.oc.detail=OpenShift CLI (oc) ist ein Befehlszeilentool zur Verwaltung von OpenShift-Clustern. Detaillierte Dokumentation ist zu finden unter https://docs.openshift.com/ cmd.pgadmin=Werkzeug Kommando für pgAdmin. diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/obsidian/ObsidianTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/obsidian/ObsidianTest.java new file mode 100644 index 00000000..4962944f --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/obsidian/ObsidianTest.java @@ -0,0 +1,91 @@ +package com.devonfw.tools.ide.tool.obsidian; + +import org.junit.jupiter.api.Test; + +import com.devonfw.tools.ide.common.Tag; +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.os.SystemInfoMock; + +/** + * Test of {@link Obsidian}. + */ +class ObsidianTest extends AbstractIdeContextTest { + + /** + * Test that the {@link Obsidian} commandlet is registered and properly classified. + */ + @Test + void testObsidianCommandletIsRegistered() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + + // act + Obsidian obsidian = context.getCommandletManager().getCommandlet(Obsidian.class); + + // assert + assertThat(obsidian).isNotNull(); + assertThat(obsidian.getName()).isEqualTo("obsidian"); + assertThat(obsidian.getTags()).containsExactly(Tag.MARK_DOWN); + } + + /** + * Test that the Windows download is not extracted since it is an installer executable that has to be started. + */ + @Test + void testIsExtractIsFalseOnWindows() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.WINDOWS_X64); + Obsidian obsidian = context.getCommandletManager().getCommandlet(Obsidian.class); + + // act + assert + assertThat(obsidian.isExtract()).isFalse(); + } + + /** + * Test that the macOS download is extracted since the *.app has to be taken out of the DMG image. + */ + @Test + void testIsExtractIsTrueOnMac() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.MAC_X64); + Obsidian obsidian = context.getCommandletManager().getCommandlet(Obsidian.class); + + // act + assert + assertThat(obsidian.isExtract()).isTrue(); + } + + /** + * Test that the Linux download is extracted since it is a tar.gz archive. + */ + @Test + void testIsExtractIsTrueOnLinux() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + Obsidian obsidian = context.getCommandletManager().getCommandlet(Obsidian.class); + + // act + assert + assertThat(obsidian.isExtract()).isTrue(); + } + + /** + * Test that the name used to find an existing installation in the Windows registry is not the lower-case tool name. + */ + @Test + void testGetWindowsRegistryAppName() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + Obsidian obsidian = context.getCommandletManager().getCommandlet(Obsidian.class); + + // act + assert + assertThat(obsidian.getWindowsRegistryAppName()).isEqualTo("Obsidian"); + } +} diff --git a/documentation/LICENSE.adoc b/documentation/LICENSE.adoc index 735801f7..ec98bd0e 100644 --- a/documentation/LICENSE.adoc +++ b/documentation/LICENSE.adoc @@ -108,6 +108,7 @@ The column `inclusion` indicates the way the component is included: |https://tomcat.apache.org/[tomcat]|Optional|https://www.apache.org/licenses/LICENSE-2.0[Apache 2.0] |https://www.oracle.com/java/technologies/jdk-mission-control.html[Java Mission Control]|Optional|https://github.com/openjdk/jmc/blob/master/license/LICENSE.txt[UPL 1.0] and https://github.com/openjdk/jmc/blob/master/license/THIRDPARTYREADME.txt[EPL 2.0] |https://www.pgadmin.org/[pgAdmin]|Optional|https://www.pgadmin.org/licence/#postgresql[The PostgreSQL Licence] +|https://obsidian.md/[Obsidian]|Optional|https://obsidian.md/license[Obsidian Terms of Service (proprietary, free for commercial use)] |https://docs.astral.sh/uv/[uv] |Optional|https://docs.astral.sh/uv/reference/policies/license/[Apache 2.0] |https://squirrel-sql.sourceforge.io/[SQuirreL SQL Client]|Optional|https://github.com/squirrel-sql-client/squirrel-sql-stable-releases/blob/main/LICENSE[LGPL 2.1] |https://www.soapui.org/[SoapUI]|Optional|https://github.com/SmartBear/soapui/blob/next/LICENSE.txt[EUPL 1.1] From dbb8a0fc777d03e23823b79e3fef83df24cc7797 Mon Sep 17 00:00:00 2001 From: quando632 Date: Tue, 15 Sep 2026 13:08:55 +0200 Subject: [PATCH 07/66] #2366: support variables and expressions in repository properties (#2398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jörg Hohwiller --- CHANGELOG.adoc | 1 + .../AbstractEnvironmentVariables.java | 5 +- .../EnvironmentVariablesSystem.java | 4 +- .../git/repository/RepositoryCommandlet.java | 40 +++++++-- .../ide/git/repository/RepositoryConfig.java | 11 +-- .../git/repository/RepositoryProperties.java | 49 ++++++++++- .../tools/ide/variable/IdeVariables.java | 8 +- .../commandlet/RepositoryCommandletTest.java | 82 +++++++++++++++++ .../repository/RepositoryPropertiesTest.java | 87 +++++++++++++++++++ .../tools/ide/variable/IdeVariablesTest.java | 27 ++++++ documentation/repository.adoc | 21 +++++ documentation/variables.adoc | 1 + 12 files changed, 318 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 1fcfb416..87c8b59e 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -11,6 +11,7 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/2190[#2190]: IDEasy destroys my python installation * https://github.com/devonfw/IDEasy/issues/2178[#2178]: Make ReleaseCommandlet independent of specific build commandlet and fix `ide build` using npm instead of yarn * https://github.com/devonfw/IDEasy/issues/2142[#2142]: Move IDE-specific metadata (.idea, .vscode) out of workspace +* https://github.com/devonfw/IDEasy/issues/2366[#2366]: Support for variables and expressions in repository properties * https://github.com/devonfw/IDEasy/issues/989[#989]: Allow expressions in template variable definitions * https://github.com/devonfw/IDEasy/issues/2252[#2252]: Add uninstall support for globally installed tools on macOS * https://github.com/devonfw/IDEasy/issues/1628[#1628]: Support for OS-specific CVEs diff --git a/cli/src/main/java/com/devonfw/tools/ide/environment/AbstractEnvironmentVariables.java b/cli/src/main/java/com/devonfw/tools/ide/environment/AbstractEnvironmentVariables.java index d591f0d1..64a82ddd 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/environment/AbstractEnvironmentVariables.java +++ b/cli/src/main/java/com/devonfw/tools/ide/environment/AbstractEnvironmentVariables.java @@ -11,6 +11,7 @@ import org.slf4j.LoggerFactory; import org.slf4j.event.Level; +import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.expression.ExpressionContext; import com.devonfw.tools.ide.expression.ExpressionFunctionManager; @@ -206,7 +207,9 @@ private String resolveRecursive(String value, Object source, int recursion, Abst return null; } if (recursion > MAX_RECURSION) { - throw new IllegalStateException( + // a circular variable reference is a configuration error of the user and therefore reported as CliException and not as IllegalStateException that + // would indicate a programming error and reach the end-user as a stacktrace + throw new CliException( "Reached maximum recursion resolving " + value + " for root variable " + context.rootSrc + " with value '" + context.rootValue + "'."); } recursion++; diff --git a/cli/src/main/java/com/devonfw/tools/ide/environment/EnvironmentVariablesSystem.java b/cli/src/main/java/com/devonfw/tools/ide/environment/EnvironmentVariablesSystem.java index 9546c6cc..94612adf 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/environment/EnvironmentVariablesSystem.java +++ b/cli/src/main/java/com/devonfw/tools/ide/environment/EnvironmentVariablesSystem.java @@ -42,7 +42,9 @@ public Map getVariables() { public String getFlat(String name) { for (VariableDefinition variable : IdeVariables.VARIABLES) { - if ((variable != IdeVariables.PATH) && (variable != IdeVariables.IDE_ROOT) && (variable != IdeVariables.HOME) && name.equals(variable.getName())) { + // USER is like PATH, IDE_ROOT and HOME an environment variable provided by the operating system and not invented by IDEasy, so it is not blocked here + if ((variable != IdeVariables.PATH) && (variable != IdeVariables.IDE_ROOT) && (variable != IdeVariables.HOME) && (variable != IdeVariables.USER) + && name.equals(variable.getName())) { return null; } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryCommandlet.java index b89a3715..aabda177 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryCommandlet.java @@ -11,6 +11,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.devonfw.tools.ide.cli.CliAbortException; +import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.commandlet.Commandlet; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.git.GitContext; @@ -74,7 +76,7 @@ protected void doRun() { boolean forceMode = this.context.isForceMode() || this.context.isForceRepositories(); Map repositoryConfigMap = new HashMap<>(propertiesFiles.size()); for (Path propertiesFile : propertiesFiles) { - RepositoryConfig config = prepareActiveRepository(propertiesFile, forceMode); + RepositoryConfig config = prepareActiveRepositoryIgnoringInvalid(propertiesFile, forceMode); if (config != null) { repositoryConfigMap.put(propertiesFile, config); } @@ -88,20 +90,44 @@ protected void doRun() { } } - private RepositoryConfig prepareActiveRepository(Path repositoryFile, boolean forceMode) { + /** + * Like {@link #prepareActiveRepository(Path, boolean)} but ignores a single invalid repository instead of aborting the setup of all others. Resolving a + * property value may fail for a malformed expression, and one broken file in the repositories folder must not prevent every other repository from being + * set up. Only used when iterating all repositories - if the user explicitly requested a single repository, the failure is reported instead. + * + * @param repositoryFile the {@link Path} to the repository properties file. + * @param forceMode - {@code true} to setup the repository even if it is not active, {@code false} otherwise. + * @return the {@link RepositoryConfig} or {@code null} if the repository shall be skipped. + */ + private RepositoryConfig prepareActiveRepositoryIgnoringInvalid(Path repositoryFile, boolean forceMode) { - RepositoryConfig config = RepositoryConfig.loadProperties(repositoryFile, this.context); - if (config == null) { + try { + return prepareActiveRepository(repositoryFile, forceMode); + } catch (CliAbortException e) { + throw e; // the user deliberately aborted - never swallow this + } catch (CliException e) { + LOG.error("Ignoring repository {} because its configuration is invalid: {}", repositoryFile, e.getMessage()); return null; } - if (!config.active()) { + } + + private RepositoryConfig prepareActiveRepository(Path repositoryFile, boolean forceMode) { + + // the active flag is evaluated before the remaining properties are read: reading a property resolves variables and expressions in its value, so an + // expression like @ask-variable would otherwise ask the user for a repository that is skipped anyway + RepositoryProperties properties = new RepositoryProperties(repositoryFile, this.context); + if (!properties.isActive()) { if (forceMode) { - LOG.info("Setup of repository {} is forced, hence proceeding ...", config.id()); + LOG.info("Setup of repository {} is forced, hence proceeding ...", properties.getId()); } else { - LOG.info("Skipping repository {} because it is not active, use --force-repositories to setup all repositories ...", config.id()); + LOG.info("Skipping repository {} because it is not active, use --force-repositories to setup all repositories ...", properties.getId()); return null; } } + RepositoryConfig config = RepositoryConfig.loadProperties(properties); + if (config == null) { + return null; + } // prepare workspace creation for correct resolution of * List workspaces = config.workspaces(); for (String workspace : workspaces) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryConfig.java b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryConfig.java index 6b327476..b8e19054 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryConfig.java +++ b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryConfig.java @@ -1,8 +1,6 @@ package com.devonfw.tools.ide.git.repository; -import java.nio.file.Path; import java.util.List; -import java.util.Properties; import java.util.Set; import com.devonfw.tools.ide.context.IdeContext; @@ -58,13 +56,12 @@ public GitUrl asGitUrl() { } /** - * @param filePath the {@link Path} to the {@link Properties} to load. - * @param context the {@link IdeContext}. - * @return the parsed {@link RepositoryConfig}. + * @param properties the {@link RepositoryProperties} to parse. The caller loads them so it can inspect e.g. the + * {@link RepositoryProperties#isActive() active flag} before the remaining properties are read and thereby resolved. + * @return the parsed {@link RepositoryConfig} or {@code null} if the properties are {@link RepositoryProperties#isInvalid() invalid}. */ - public static RepositoryConfig loadProperties(Path filePath, IdeContext context) { + static RepositoryConfig loadProperties(RepositoryProperties properties) { - RepositoryProperties properties = new RepositoryProperties(filePath, context); String id = properties.getId(); RepositoryConfig config = new RepositoryConfig(id, properties.getPath(), properties.getWorkingSets(), properties.getWorkspaces(), properties.getGitUrl(), properties.getGitBranch(), properties.getBuildPath(), properties.getBuildCmd(), properties.getImports(), properties.getLinks(), properties.getRemotes(), diff --git a/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryProperties.java b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryProperties.java index 27d3969b..a36a48dd 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryProperties.java +++ b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryProperties.java @@ -4,8 +4,10 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Properties; import java.util.Set; import java.util.regex.Pattern; @@ -47,6 +49,11 @@ final class RepositoryProperties { private final Properties properties; + private final IdeContext context; + + /** Cache of already resolved property values so that resolving never happens twice - see {@link #doGetProperty(String, String)}. */ + private final Map resolvedProperties; + private boolean invalid; /** @@ -56,7 +63,7 @@ final class RepositoryProperties { * @param context the {@link IdeContext}. */ public RepositoryProperties(Path file, IdeContext context) { - this(file, context.getFileAccess().readProperties(file)); + this(file, context.getFileAccess().readProperties(file), context); } /** @@ -64,9 +71,20 @@ public RepositoryProperties(Path file, IdeContext context) { * @param properties the actual {@link Properties} loaded from the file. */ RepositoryProperties(Path file, Properties properties) { + this(file, properties, null); + } + + /** + * @param file the {@link Path} to the properties file. + * @param properties the actual {@link Properties} loaded from the file. + * @param context the {@link IdeContext} used to resolve variables and expressions or {@code null} to disable resolution. + */ + RepositoryProperties(Path file, Properties properties, IdeContext context) { super(); this.file = file; this.properties = properties; + this.context = context; + this.resolvedProperties = new HashMap<>(); } /** @@ -120,6 +138,18 @@ public String getProperty(String name, String legacyName, boolean required) { private String doGetProperty(String name, String legacyName) { + // the result is cached since resolving may have side-effects: an expression like @ask-variable asks the user, so reading the same property twice + // (e.g. the active flag, that is read by RepositoryCommandlet and again by RepositoryConfig) must not ask twice. + if (this.resolvedProperties.containsKey(name)) { + return this.resolvedProperties.get(name); + } + String value = resolve(doGetRawProperty(name, legacyName)); + this.resolvedProperties.put(name, value); + return value; + } + + private String doGetRawProperty(String name, String legacyName) { + String value = this.properties.getProperty(name); if (value != null) { return value; @@ -142,6 +172,23 @@ private String doGetProperty(String name, String legacyName) { return value; } + /** + * Resolves {@link com.devonfw.tools.ide.variable.VariableSyntax#SQUARE variables} and expressions in the given property value. This is done centrally here so + * that it applies to every property and happens before any validation (e.g. of a {@link #getPath() path}) that would otherwise reject the syntax of an + * unresolved value. + * + * @param value the raw property value or {@code null}. + * @return the given value with variables and expressions resolved. + */ + private String resolve(String value) { + + if ((value == null) || (this.context == null)) { + return value; + } + // legacy support is disabled on purpose: the ${...} syntax is common in build commands (e.g. maven properties in build_cmd) and must not be touched + return this.context.getVariables().resolve(value, this.file, false); + } + private static boolean isEmpty(String value) { return (value == null) || value.isBlank(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/variable/IdeVariables.java b/cli/src/main/java/com/devonfw/tools/ide/variable/IdeVariables.java index 98fef82c..d7a81bf8 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/variable/IdeVariables.java +++ b/cli/src/main/java/com/devonfw/tools/ide/variable/IdeVariables.java @@ -22,6 +22,12 @@ public interface IdeVariables { /** {@link VariableDefinition} for {@link com.devonfw.tools.ide.context.IdeContext#getUserHome() HOME}. */ VariableDefinitionPath HOME = new VariableDefinitionPath("HOME", null, IdeContext::getUserHome, true); + /** + * {@link VariableDefinition} for the login name of the current user. Linux and macOS provide this as environment variable while Windows provides USERNAME + * instead, which is therefore used as fallback so that {@code $[USER]} works cross-platform. + */ + VariableDefinitionString USER = new VariableDefinitionString("USER", null, c -> c.getSystem().getEnv("USERNAME")); + /** {@link VariableDefinition} for {@link com.devonfw.tools.ide.context.IdeContext#getWorkspaceName() WORKSPACE}. */ VariableDefinitionString WORKSPACE = new VariableDefinitionString("WORKSPACE", null, IdeContext::getWorkspaceName, true); @@ -134,7 +140,7 @@ public interface IdeVariables { c -> Boolean.FALSE); /** A {@link Collection} with all pre-defined {@link VariableDefinition}s. */ - Collection> VARIABLES = List.of(PATH, HOME, WORKSPACE_PATH, IDE_HOME, IDE_ROOT, WORKSPACE, IDE_TOOLS, HTTP_VERSIONS, + Collection> VARIABLES = List.of(PATH, HOME, USER, WORKSPACE_PATH, IDE_HOME, IDE_ROOT, WORKSPACE, IDE_TOOLS, HTTP_VERSIONS, CREATE_START_SCRIPTS, IDE_MIN_VERSION, MVN_VERSION, M2_REPO, DOCKER_EDITION, MVN_BUILD_OPTS, NPM_BUILD_OPTS, NPM_CONFIG_USERCONFIG, GRADLE_BUILD_OPTS, GRADLE_USER_HOME, diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/RepositoryCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/RepositoryCommandletTest.java index 310c7304..4a5b9bd6 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/RepositoryCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/RepositoryCommandletTest.java @@ -9,6 +9,7 @@ import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.environment.EnvironmentVariablesType; import com.devonfw.tools.ide.git.GitContextImplMock; import com.devonfw.tools.ide.git.repository.RepositoryCommandlet; import com.devonfw.tools.ide.io.FileAccess; @@ -86,6 +87,87 @@ void testSetupAllRepositoriesInactive() { assertThat(context).logAtInfo().hasMessage("Skipping repository test because it is not active, use --force-repositories to setup all repositories ..."); } + @Test + void testInactiveRepositoryIsNotResolved() { + + // arrange + IdeTestContext context = newContext(IdeContext.FOLDER_REPOSITORY); + Properties properties = createDefaultProperties(); + properties.setProperty("git_url", "https://github.com/$[UNDEFINED_VARIABLE]/" + TEST_GIT_REPO + ".git"); + RepositoryCommandlet rc = context.getCommandletManager().getCommandlet(RepositoryCommandlet.class); + saveProperties(context, properties); + // act + rc.run(); + // assert + assertThat(context).logAtInfo().hasMessage("Skipping repository test because it is not active, use --force-repositories to setup all repositories ..."); + // an inactive repository must not be resolved at all so that an expression like @ask-variable never asks the user for a repository that is skipped + assertThat(context).logAtWarning().hasNoMessageContaining("Undefined variable $[UNDEFINED_VARIABLE]"); + } + + @Test + void testActiveExpressionIsEvaluatedOnlyOnce() { + + // arrange + IdeTestContext context = newContext(IdeContext.FOLDER_REPOSITORY); + Properties properties = createDefaultProperties(); + // an expression with an empty variable name always asks and is never persisted, so a second evaluation would ask again + properties.setProperty("active", "@ask-variable('', 'Setup this repository?')"); + // exactly one answer is available: if the property is evaluated twice, readLine fails with "End of answers reached!" + context.setAnswers("true"); + RepositoryCommandlet rc = context.getCommandletManager().getCommandlet(RepositoryCommandlet.class); + saveProperties(context, properties); + // act + rc.run(); + // assert + assertThat(context.getIdeHome().resolve(IdeContext.FOLDER_WORKSPACES).resolve(TEST_WORKSPACE).resolve(TEST_REPO)).isDirectory(); + } + + @Test + void testInvalidExpressionDoesNotPreventOtherRepositories() { + + // arrange + IdeTestContext context = newContext(IdeContext.FOLDER_REPOSITORY); + Properties broken = createDefaultProperties(); + broken.setProperty("active", "true"); + broken.setProperty("path", "broken-repo"); + // @ask-variable requires at least one argument, so resolving this git_url throws a CliException + broken.setProperty("git_url", "https://github.com/@ask-variable()/broken.git"); + saveProperties(context, broken, "broken.properties"); + Properties good = createDefaultProperties(); + good.setProperty("active", "true"); + saveProperties(context, good); + RepositoryCommandlet rc = context.getCommandletManager().getCommandlet(RepositoryCommandlet.class); + // act + rc.run(); + // assert + assertThat(context.getIdeHome().resolve(IdeContext.FOLDER_WORKSPACES).resolve(TEST_WORKSPACE).resolve(TEST_REPO)).isDirectory(); + assertThat(context).logAtError().hasMessageContaining("Invalid template expression"); + } + + @Test + void testCircularVariableDoesNotPreventOtherRepositories() { + + // arrange + IdeTestContext context = newContext(IdeContext.FOLDER_REPOSITORY); + context.getVariables().getByType(EnvironmentVariablesType.CONF).set("REPO_A", "$[REPO_B]"); + context.getVariables().getByType(EnvironmentVariablesType.CONF).set("REPO_B", "$[REPO_A]"); + Properties broken = createDefaultProperties(); + broken.setProperty("active", "true"); + broken.setProperty("path", "broken-repo"); + // the two variables reference each other, so resolving this git_url exceeds the maximum recursion + broken.setProperty("git_url", "https://github.com/$[REPO_A]/broken.git"); + saveProperties(context, broken, "broken.properties"); + Properties good = createDefaultProperties(); + good.setProperty("active", "true"); + saveProperties(context, good); + RepositoryCommandlet rc = context.getCommandletManager().getCommandlet(RepositoryCommandlet.class); + // act + rc.run(); + // assert + assertThat(context.getIdeHome().resolve(IdeContext.FOLDER_WORKSPACES).resolve(TEST_WORKSPACE).resolve(TEST_REPO)).isDirectory(); + assertThat(context).logAtError().hasMessageContaining("Reached maximum recursion"); + } + @Test void testSetupSpecificRepositoryWithoutPath() { diff --git a/cli/src/test/java/com/devonfw/tools/ide/git/repository/RepositoryPropertiesTest.java b/cli/src/test/java/com/devonfw/tools/ide/git/repository/RepositoryPropertiesTest.java index 06e512f1..bba18aae 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/git/repository/RepositoryPropertiesTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/git/repository/RepositoryPropertiesTest.java @@ -8,6 +8,9 @@ import org.junit.jupiter.api.Test; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.environment.EnvironmentVariablesType; + /** * Test of {@link RepositoryProperties}. */ @@ -27,6 +30,10 @@ class RepositoryPropertiesTest { private static final String URL_EXAMPLE_REPO = "https://example.com/repo.git"; private static final String URL_SSH_DEVONFW = "git@github.com:devonfw/ide-settings.git"; + // Test variable used to verify resolution of variables and expressions + private static final String VARIABLE_GIT_USER = "GIT_USER"; + private static final String GIT_USER = "quando632"; + @Test void testGetId() { @@ -57,6 +64,18 @@ private static RepositoryProperties properties(String filename, Properties prope return new RepositoryProperties(Path.of(filename), properties); } + private static RepositoryProperties properties(String filename, Properties properties, IdeTestContext context) { + + return new RepositoryProperties(Path.of(filename), properties, context); + } + + private static IdeTestContext contextWithVariable(String name, String value) { + + IdeTestContext context = new IdeTestContext(); + context.getVariables().getByType(EnvironmentVariablesType.CONF).set(name, value); + return context; + } + @Test void testGetRemotesEmpty() { @@ -144,4 +163,72 @@ void testGetRemotesSshUrl() { assertThat(remotes.get(0).name()).isEqualTo(REMOTE_NAME_UPSTREAM); assertThat(remotes.get(0).url()).isEqualTo(URL_SSH_DEVONFW); } + + @Test + void testGitUrlResolvesVariable() { + + IdeTestContext context = contextWithVariable(VARIABLE_GIT_USER, GIT_USER); + Properties props = new Properties(); + props.setProperty("git_url", "https://github.com/$[" + VARIABLE_GIT_USER + "]/IDEasy.git"); + RepositoryProperties repositoryProperties = properties("test.properties", props, context); + + assertThat(repositoryProperties.getGitUrl()).isEqualTo("https://github.com/" + GIT_USER + "/IDEasy.git"); + } + + @Test + void testGitRemoteResolvesVariable() { + + IdeTestContext context = contextWithVariable(VARIABLE_GIT_USER, GIT_USER); + Properties props = new Properties(); + props.setProperty("git_remote", REMOTE_NAME_FORK + ":https://github.com/$[" + VARIABLE_GIT_USER + "]/IDEasy.git"); + RepositoryProperties repositoryProperties = properties("test.properties", props, context); + + List remotes = repositoryProperties.getRemotes(); + assertThat(remotes).hasSize(1); + assertThat(remotes.get(0).name()).isEqualTo(REMOTE_NAME_FORK); + assertThat(remotes.get(0).url()).isEqualTo("https://github.com/" + GIT_USER + "/IDEasy.git"); + } + + @Test + void testPathResolvesVariableBeforeSanitizing() { + + IdeTestContext context = contextWithVariable(VARIABLE_GIT_USER, GIT_USER); + Properties props = new Properties(); + props.setProperty("path", "repos/$[" + VARIABLE_GIT_USER + "]"); + RepositoryProperties repositoryProperties = properties("test.properties", props, context); + + assertThat(repositoryProperties.getPath()).isEqualTo("repos/" + GIT_USER); + } + + @Test + void testUndefinedVariableIsKept() { + + IdeTestContext context = new IdeTestContext(); + Properties props = new Properties(); + props.setProperty("git_url", "https://github.com/$[UNDEFINED_VARIABLE]/IDEasy.git"); + RepositoryProperties repositoryProperties = properties("test.properties", props, context); + + assertThat(repositoryProperties.getGitUrl()).isEqualTo("https://github.com/$[UNDEFINED_VARIABLE]/IDEasy.git"); + } + + @Test + void testLegacyCurlySyntaxIsNotResolved() { + + IdeTestContext context = contextWithVariable("project.version", "1.2.3"); + Properties props = new Properties(); + props.setProperty("build_cmd", "mvn -Dversion=${project.version} clean install"); + RepositoryProperties repositoryProperties = properties("test.properties", props, context); + + assertThat(repositoryProperties.getBuildCmd()).isEqualTo("mvn -Dversion=${project.version} clean install"); + } + + @Test + void testWithoutContextNothingIsResolved() { + + Properties props = new Properties(); + props.setProperty("git_url", "https://github.com/$[" + VARIABLE_GIT_USER + "]/IDEasy.git"); + RepositoryProperties repositoryProperties = properties("test.properties", props); + + assertThat(repositoryProperties.getGitUrl()).isEqualTo("https://github.com/$[" + VARIABLE_GIT_USER + "]/IDEasy.git"); + } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/variable/IdeVariablesTest.java b/cli/src/test/java/com/devonfw/tools/ide/variable/IdeVariablesTest.java index b6617caa..952b6bef 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/variable/IdeVariablesTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/variable/IdeVariablesTest.java @@ -39,6 +39,33 @@ void testHttpProtocols() { assertThat(httpVersions2_11).containsExactly(Version.HTTP_2, Version.HTTP_1_1); } + /** Test of {@link IdeVariables#USER} on Linux and macOS where the USER environment variable is defined. */ + @Test + void testUserFromEnvironmentVariable() { + + // arrange + IdeTestContext context = new IdeTestContext(); + context.getSystem().setEnv("USER", "unix-login"); + context.getSystem().setEnv("USERNAME", "windows-login"); + // act + String user = IdeVariables.USER.get(context); + // assert + assertThat(user).isEqualTo("unix-login"); + } + + /** Test of {@link IdeVariables#USER} on Windows where only the USERNAME environment variable is defined. */ + @Test + void testUserFallsBackToUsername() { + + // arrange + IdeTestContext context = new IdeTestContext(); + context.getSystem().setEnv("USERNAME", "windows-login"); + // act + String user = IdeVariables.USER.get(context); + // assert + assertThat(user).isEqualTo("windows-login"); + } + /** Test of {@link IdeVariables#IDE_TOOLS} with bash array syntax using commas. */ @Test void testIdeToolsWithCommasInBashArray() { diff --git a/documentation/repository.adoc b/documentation/repository.adoc index c9193af9..e76f4698 100644 --- a/documentation/repository.adoc +++ b/documentation/repository.adoc @@ -54,3 +54,24 @@ If omitted no build is triggered. |`link`|CSV of `«link»[=«path»]`|(optional) Comma separated list of symbolic links to link the repository into the workspace top-level folder. For a real-world use-case of this feature see link:ai.adoc[AI usage]. |`active`|`true`|(optional) If set to `false` the repository is skipped during the link:setup.adoc[setup]. |=== + +== Variables and expressions + +Every value in a repository `.properties` file may contain link:variables.adoc[variables] using the `$[«VARIABLE»]` syntax as well as link:configurator.adoc#functions[expression functions] using the `@«function»(...)` syntax. +They are resolved before the value is used, so they also work in properties that are validated such as `path`. + +A typical use-case is to clone the fork of the current user instead of the upstream repository and to add the upstream as an additional remote: + +``` +path=IDEasy +git_url=https://github.com/@ask-variable("GIT_USER", "Please enter your github login:", "home", "$[USER]")/IDEasy.git +git_remote=upstream:https://github.com/devonfw/IDEasy.git +git_branch=main +import=eclipse +active=true +``` + +On the first setup the user is asked for the github login, the answer is stored as `GIT_USER` in `~/.ide/ide.properties` (`home`) and reused on every following run. +The default `$[USER]` is the login name of the current user and works on all platforms. + +NOTE: A repository with `active=false` is skipped before its properties are read, so a question from `@ask-variable` is never asked for a repository that is not set up. diff --git a/documentation/variables.adoc b/documentation/variables.adoc index 87233b83..6adba5cb 100644 --- a/documentation/variables.adoc +++ b/documentation/variables.adoc @@ -22,6 +22,7 @@ See also link:https://github.com/devonfw/IDEasy/blob/main/cli/src/main/java/com/ |`BASH_PATH`|e.g. `C:\Program Files\Git\usr\bin\bash.exe`|Absolute path to your bash. Only used as fallback on Windows if bash could not be found from registry. |`IDE_TOOLS`|`(java mvn node npm)`|List of tools that should be installed by default on project creation. |`CREATE_START_SCRIPTS`|e.g. `eclipse, intellij, vscode`|List of IDEs that shall be used by developers in the project and therefore start-scripts are created on setup. E.g. `(eclipse intellij vscode)` +|`USER`|e.g. `jdoe`|The login name of the current user. Provided as environment variable by Linux and macOS while Windows provides `USERNAME` instead, which is used as fallback so that `$[USER]` works on all platforms. |`WORKSPACE`|`main`|The link:workspaces.adoc[workspace] you are currently in. Defaults to `main` if you are not inside a link:workspaces.adoc[workspace]. Never set this variable in any `ide.properties` file. |`WORKSPACE_PATH`|`$IDE_HOME/workspaces/$WORKSPACE`|Absolute path to current link:workspaces.adoc[workspace]. Never set this variable in any `ide.properties` file. |`«TOOL»_VERSION`|`*`|The version of the tool `«TOOL»` to install and use (e.g. `ECLIPSE_VERSION` or `MVN_VERSION`). From 6a714c7e2f5d917d60831bc0e543f3d00268a81e Mon Sep 17 00:00:00 2001 From: Laert Llaveshi Date: Thu, 17 Sep 2026 17:00:24 +0200 Subject: [PATCH 08/66] #2488: Add compiler and SDK to MSVC installation (#2489) --- CHANGELOG.adoc | 1 + cli/src/main/java/com/devonfw/tools/ide/tool/msvc/Msvc.java | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 87c8b59e..dd4e07c0 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -6,6 +6,7 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDE Release with new features and bugfixes: +* https://github.com/devonfw/IDEasy/issues/2488[#2488]: Add compiler and SDK to MSVC installation * https://github.com/devonfw/IDEasy/issues/2103[#2103]: Integrate Obsidian * https://github.com/devonfw/IDEasy/issues/1695[#1695]: Project creation logic extended with health checks and removed `--code` flag. * https://github.com/devonfw/IDEasy/issues/2190[#2190]: IDEasy destroys my python installation diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/msvc/Msvc.java b/cli/src/main/java/com/devonfw/tools/ide/tool/msvc/Msvc.java index 2f5ca8bc..c3a6f785 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/msvc/Msvc.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/msvc/Msvc.java @@ -46,6 +46,8 @@ protected void installDownloadedToolPayload(ToolInstallRequest request, Path ins .withExitCodeAcceptor(code -> (code == 0) || (code == 3010)) .addArgs("--installPath", installationPath.toString(), "--add", "Microsoft.VisualStudio.Workload.VCTools", + "--add", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "--add", "Microsoft.VisualStudio.Component.Windows11SDK.26100", "--quiet", "--wait", "--norestart", "--nocache") .run(); } From 35a79458ae8cbffeb04737ecd881c476f0fb6afe Mon Sep 17 00:00:00 2001 From: Hiepiscus Date: Mon, 21 Sep 2026 09:53:29 +0200 Subject: [PATCH 09/66] #2193: add alternatives and dependencies to auto-completion entries (#2390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jörg Hohwiller --- CHANGELOG.adoc | 1 + .../completion/AutoCompletionRegistry.java | 49 +++++---- .../tools/ide/completion/CompletionEntry.java | 79 +++++++++----- .../tools/ide/tool/mvn/MavenCommandlet.java | 9 +- .../tools/ide/completion/CompleteTest.java | 101 +++++++++++++++++- 5 files changed, 184 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index dd4e07c0..1438aa2d 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -6,6 +6,7 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDE Release with new features and bugfixes: +* https://github.com/devonfw/IDEasy/issues/2193[#2193]: Added support for conditional auto-completion * https://github.com/devonfw/IDEasy/issues/2488[#2488]: Add compiler and SDK to MSVC installation * https://github.com/devonfw/IDEasy/issues/2103[#2103]: Integrate Obsidian * https://github.com/devonfw/IDEasy/issues/1695[#1695]: Project creation logic extended with health checks and removed `--code` flag. diff --git a/cli/src/main/java/com/devonfw/tools/ide/completion/AutoCompletionRegistry.java b/cli/src/main/java/com/devonfw/tools/ide/completion/AutoCompletionRegistry.java index 7948f360..30f0830f 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/completion/AutoCompletionRegistry.java +++ b/cli/src/main/java/com/devonfw/tools/ide/completion/AutoCompletionRegistry.java @@ -1,7 +1,10 @@ package com.devonfw.tools.ide.completion; -import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Set; import com.devonfw.tools.ide.commandlet.Commandlet; import com.devonfw.tools.ide.property.Property; @@ -11,34 +14,36 @@ */ public class AutoCompletionRegistry { - /** - * The registered completion candidates. + * The registered completion entries mapped by their candidate names. */ - private final List entries = new ArrayList<>(); - + private final Map entryMap = new LinkedHashMap<>(); /** - * Adds a new completion candidate. + * Adds a completion candidate together with its alternatives. * * @param candidate the candidate to add. + * @param alternatives to add a long with the candidate + * @return the {@link CompletionEntry} created for {@code candidate} for configuration. */ - public void add(String candidate) { - this.entries.add(new CompletionEntry(candidate)); - } + public CompletionEntry add(String candidate, String... alternatives) { - /** - * Adds a new completion candidate together with a synonym. For now this adds both values. - * - * @param candidate the candidate to add. - * @param synonym to add a long with the candidate - */ - public void add(String candidate, String synonym) { - CompletionEntry entry = new CompletionEntry(candidate); - entry.addSynonym(synonym); - this.entries.add(entry); - } + Set names = new LinkedHashSet<>(); + names.add(candidate); + names.addAll(List.of(alternatives)); + + Set immutableNames = Set.copyOf(names); + CompletionEntry entry = new CompletionEntry(candidate, names); + this.entryMap.put(candidate, entry); + + for (String alternative : immutableNames) { + if (!alternative.equals(candidate)) { + this.entryMap.put(alternative, new CompletionEntry(alternative, immutableNames)); + } + } + return entry; + } /** * Adds all candidates matching the given argument to the collector. @@ -51,10 +56,8 @@ public void add(String candidate, String synonym) { public void complete(String arg, CompletionCandidateCollector collector, Property property, Commandlet commandlet) { - for (CompletionEntry entry : this.entries) { + for (CompletionEntry entry : this.entryMap.values()) { entry.complete(arg, collector, property, commandlet); } } - - } diff --git a/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionEntry.java b/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionEntry.java index 9df9cc74..ef839103 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionEntry.java +++ b/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionEntry.java @@ -1,6 +1,6 @@ package com.devonfw.tools.ide.completion; -import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Set; @@ -8,59 +8,90 @@ import com.devonfw.tools.ide.property.Property; /** - * A completion candidate that may have one or more synonyms. When any one of the candidate or its synonyms has already been provided on the command line, none - * of them will be suggested again. + * A completion candidate with optional alternatives and dependencies. The candidate and all of its alternatives represent completion options. Therefore, this + * entry is not suggested if the candidate or any of its alternatives has already been provided. */ public class CompletionEntry { - /** The primary candidate string. */ - private String candidate; + /** The candidate to suggest. */ + private final String candidate; - /** List of synonym strings for this candidate. */ - private List synonyms = new ArrayList<>(); + /** The names of this entry, including the candidate itself. */ + private final Set alternatives; + + /** The candidates of which at least one has to be provided before this entry may be suggested. */ + private final Set dependencies = new HashSet<>(); /** - * The constructor. + * The constructor for a completion entry with alternatives. * - * @param candidate the primary candidate to add. + * @param candidate the candidate to suggest. + * @param alternatives names of this entry, including {@code candidate}. */ - public CompletionEntry(String candidate) { + public CompletionEntry(String candidate, Set alternatives) { this.candidate = candidate; + this.alternatives = alternatives; } /** - * Adds a synonym for this candidate. - * - * @param synonym the synonym to add. + * @return the primary candidate string. */ - public void addSynonym(String synonym) { - this.synonyms.add(synonym); + public String getCandidate() { + return candidate; } /** - * Performs auto-completion for this entry, skipping it entirely if the candidate or any of its synonyms has already been provided on the command line. + * Performs auto-completion for this entry, skipping it entirely if the candidate or any of its alternatives has already been provided on the command line. * * @param arg the current argument being completed. - * @param collector the {@link CompletionCandidateCollector} to add matching candidates to. - * @param property the {@link Property} that triggered completion. + * @param collector the {@link CompletionCandidateCollector} to add the candidates to. + * @param property the {@link Property} that triggered the completion. * @param commandlet the {@link Commandlet} owning the property. */ public void complete(String arg, CompletionCandidateCollector collector, Property property, Commandlet commandlet) { Set alreadyProvided = collector.getAlreadyProvided(); - if (alreadyProvided != null && (alreadyProvided.contains(this.candidate) || synonyms.stream().anyMatch(alreadyProvided::contains))) { + if (!isDependencySatisfied(alreadyProvided)) { + return; + } + + if (isProvided(alreadyProvided)) { return; } if (candidate.startsWith(arg)) { collector.add(candidate, "", property, commandlet); } + } - for (String synonym : synonyms) { - if (synonym.startsWith(arg)) { - collector.add(synonym, "", property, commandlet); - } - } + /** + * Checks whether the dependencies of this entry are satisfied. + * + * @param alreadyProvided the already provided arguments. + * @return {@code true} if no dependency is configured or at least one dependency has been provided, {@code false} otherwise. + */ + private boolean isDependencySatisfied(Set alreadyProvided) { + + return this.dependencies.isEmpty() || this.dependencies.stream().anyMatch(alreadyProvided::contains); } + /** + * Checks if this candidate or any of its alternatives was already provided. + * + * @param alreadyProvided the set of already provided arguments. + * @return {@code true} if already provided, {@code false} otherwise. + */ + public boolean isProvided(Set alreadyProvided) { + return this.alternatives.stream().anyMatch(alreadyProvided::contains); + } + + /** + * Adds candidates of which at least one has to be provided before this entry may be suggested. + * + * @param entries array of {@link CompletionEntry} objects of which at least one must be provided. + */ + public void addDependency(String... entries) { + + this.dependencies.addAll(List.of(entries)); + } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/mvn/MavenCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/mvn/MavenCommandlet.java index d843e41e..b3fee0ac 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/mvn/MavenCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/mvn/MavenCommandlet.java @@ -3,7 +3,6 @@ import java.util.Set; - import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.completion.AutoCompletionRegistry; import com.devonfw.tools.ide.context.IdeContext; @@ -48,9 +47,6 @@ protected void initAutoCompletionRegistry(AutoCompletionRegistry registry) { registry.add("help:effective-settings"); registry.add("-DskipTests"); registry.add("-Dmaven.test.skip=true"); - registry.add("exec:java"); - registry.add("-Dexec.mainClass="); - registry.add("-Dexec.args="); registry.add("-P"); registry.add("-pl"); registry.add("-am"); @@ -75,7 +71,8 @@ protected void initAutoCompletionRegistry(AutoCompletionRegistry registry) { registry.add("-Dstyle.color="); registry.add("-Duser.dir="); registry.add("-Duser.home="); + registry.add("exec:java", "exec:exec"); + registry.add("-Dexec.mainClass=").addDependency("exec:java"); + registry.add("-Dexec.args=").addDependency("exec:java", "exec:exec"); } } - - diff --git a/cli/src/test/java/com/devonfw/tools/ide/completion/CompleteTest.java b/cli/src/test/java/com/devonfw/tools/ide/completion/CompleteTest.java index eaa00527..abfb3fe3 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/completion/CompleteTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/completion/CompleteTest.java @@ -407,8 +407,8 @@ void testSynonymFilteringWithProvidedSynonym() { } /** - * Test that completion works for a second tool argument (e.g. "ide mvn clean [tab]"), which is the real-world scenario - * that previously failed because the multivalued arguments property consumed the completion marker greedily. + * Test that completion works for a second tool argument (e.g. "ide mvn clean [tab]"), which is the real-world scenario that previously failed because the + * multivalued arguments property consumed the completion marker greedily. */ @Test void testCompleteMavenSecondToolArgument() { @@ -425,4 +425,101 @@ void testCompleteMavenSecondToolArgument() { assertThat(candidates.stream().map(CompletionCandidate::text)) .contains("dependency:list", "dependency:tree", "deploy"); } + + /** + * Verifies that {@code exec:exec} is not suggested when {@code exec:java} has already been provided as an alternative command. + */ + @Test + void testAlternativeFilteringWhenOtherAlternativeProvidedJava() { + + // arrange + AbstractIdeContext context = newContext(PROJECT_BASIC, null, false); + String[] argsArray = { "mvn", "exec:java", "" }; + CliArguments args = CliArguments.ofCompletion(argsArray); + CompletionCandidateCollector collector = createCollector(context, argsArray); + + // act + List candidates = context.complete(args, collector, true); + + // assert + List texts = candidates.stream().map(CompletionCandidate::text).toList(); + assertThat(texts).doesNotContain("exec:exec"); + } + + /** + * Verifies that {@code exec:java} is not suggested when {@code exec:exec} has already been provided as an alternative command. + */ + @Test + void testAlternativeFilteringWhenOtherAlternativeProvidedExec() { + + // arrange + AbstractIdeContext context = newContext(PROJECT_BASIC, null, false); + String[] argsArray = { "mvn", "exec:exec", "" }; + CliArguments args = CliArguments.ofCompletion(argsArray); + CompletionCandidateCollector collector = createCollector(context, argsArray); + + // act + List candidates = context.complete(args, collector, true); + + // assert + List texts = candidates.stream().map(CompletionCandidate::text).toList(); + assertThat(texts).doesNotContain("exec:java"); + } + + /** + * Test that an entry with an unsatisfied dependency is not suggested. + */ + @Test + void testDependencyNotSatisfiedIsNotSuggested() { + + // arrange + AbstractIdeContext context = newContext(PROJECT_BASIC, null, false); + String[] argsArray = { "mvn", "-Dexec.main" }; + CliArguments args = CliArguments.ofCompletion(argsArray); + CompletionCandidateCollector collector = createCollector(context, argsArray); + + // act + List candidates = context.complete(args, collector, true); + + // assert + assertThat(candidates.stream().map(CompletionCandidate::text)).doesNotContain("-Dexec.mainClass="); + } + + /** + * Test that an entry with a satisfied dependency is suggested. + */ + @Test + void testDependencySatisfiedIsSuggested() { + + // arrange + AbstractIdeContext context = newContext(PROJECT_BASIC, null, false); + String[] argsArray = { "mvn", "exec:java", "-Dexec.mainCla" }; + CliArguments args = CliArguments.ofCompletion(argsArray); + CompletionCandidateCollector collector = createCollector(context, argsArray); + + // act + List candidates = context.complete(args, collector, true); + + // assert + assertThat(candidates.stream().map(CompletionCandidate::text)).contains("-Dexec.mainClass="); + } + + /** + * Test that an entry with an OR-dependency is suggested if any alternative of the group is provided. + */ + @Test + void testDependencyOrGroupSatisfiedByEitherAlternative() { + + // arrange + AbstractIdeContext context = newContext(PROJECT_BASIC, null, false); + String[] argsArray = { "mvn", "exec:exec", "-Dexec.arg" }; + CliArguments args = CliArguments.ofCompletion(argsArray); + CompletionCandidateCollector collector = createCollector(context, argsArray); + + // act + List candidates = context.complete(args, collector, true); + + // assert + assertThat(candidates.stream().map(CompletionCandidate::text)).contains("-Dexec.args="); + } } From 3621ffeee0db6df286caf68fa07166303a494dd6 Mon Sep 17 00:00:00 2001 From: Hiepiscus Date: Mon, 21 Sep 2026 10:07:13 +0200 Subject: [PATCH 10/66] #2338: Add a new check commandlet (#2355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jörg Hohwiller --- CHANGELOG.adoc | 1 + .../ide/commandlet/CommandletManagerImpl.java | 2 + .../ide/commandlet/check/CheckCommandlet.java | 137 ++++++++++++++++++ .../ide/commandlet/check/CheckIssue.java | 92 ++++++++++++ .../check/MissingGitignoreIssue.java | 37 +++++ .../check/MissingGitignoreRuleIssue.java | 46 ++++++ .../com/devonfw/tools/ide/git/GitContext.java | 8 + .../devonfw/tools/ide/git/GitContextImpl.java | 16 ++ cli/src/main/resources/nls/Help.properties | 3 + cli/src/main/resources/nls/Help_de.properties | 3 + .../ide/commandlet/CheckCommandletTest.java | 125 ++++++++++++++++ 11 files changed, 470 insertions(+) create mode 100644 cli/src/main/java/com/devonfw/tools/ide/commandlet/check/CheckCommandlet.java create mode 100644 cli/src/main/java/com/devonfw/tools/ide/commandlet/check/CheckIssue.java create mode 100644 cli/src/main/java/com/devonfw/tools/ide/commandlet/check/MissingGitignoreIssue.java create mode 100644 cli/src/main/java/com/devonfw/tools/ide/commandlet/check/MissingGitignoreRuleIssue.java create mode 100644 cli/src/test/java/com/devonfw/tools/ide/commandlet/CheckCommandletTest.java diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 1438aa2d..9dd8510d 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -6,6 +6,7 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDE Release with new features and bugfixes: +* https://github.com/devonfw/IDEasy/issues/2338[#2338]: Create first basic version of check commandlet * https://github.com/devonfw/IDEasy/issues/2193[#2193]: Added support for conditional auto-completion * https://github.com/devonfw/IDEasy/issues/2488[#2488]: Add compiler and SDK to MSVC installation * https://github.com/devonfw/IDEasy/issues/2103[#2103]: Integrate Obsidian diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java index 2727e051..ad69d9fd 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java @@ -14,6 +14,7 @@ import com.devonfw.tools.ide.cli.CliArgument; import com.devonfw.tools.ide.cli.CliArguments; +import com.devonfw.tools.ide.commandlet.check.CheckCommandlet; import com.devonfw.tools.ide.commandlet.cleanup.CleanupCommandlet; import com.devonfw.tools.ide.commandlet.update.UpdateCommandlet; import com.devonfw.tools.ide.completion.CompletionCandidateCollector; @@ -195,6 +196,7 @@ public CommandletManagerImpl(IdeContext context) { add(new Just(context)); add(new SoapUi(context)); add(new Ruff(context)); + add(new CheckCommandlet(context)); add(new Obsidian(context)); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/CheckCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/CheckCommandlet.java new file mode 100644 index 00000000..507f1dd5 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/CheckCommandlet.java @@ -0,0 +1,137 @@ +package com.devonfw.tools.ide.commandlet.check; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.tools.ide.cli.CliException; +import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.git.GitContext; +import com.devonfw.tools.ide.log.IdeLogLevel; +import com.devonfw.tools.ide.property.FlagProperty; + +/** + * {@link Commandlet} to check the current repository for best-practices, starting from the current working directory (CWD). + */ +public class CheckCommandlet extends Commandlet { + + private static final Logger LOG = LoggerFactory.getLogger(CheckCommandlet.class); + + /** + * rule to exclude all hidden files and folders. + */ + public static final String RULE_IGNORE_HIDDEN = ".*"; + + /** + * rule to ensure that the .gitignore file is committed. + */ + public static final String RULE_UNIGNORE_GITIGNORE = "!" + MissingGitignoreIssue.GITIGNORE; + + /** + * Flag to fix issues + */ + public final FlagProperty fix; + + /** + * The constructor. + * + * @param context the {@link IdeContext}. + */ + public CheckCommandlet(IdeContext context) { + super(context); + addKeyword(getName()); + this.fix = add(new FlagProperty("--fix")); + + } + + @Override + public String getName() { + + return "check"; + } + + @Override + public boolean isIdeRootRequired() { + return false; + } + + @Override + protected void doRun() { + + List issues = new ArrayList<>(); + + Path repositoryRoot = findRepositoryRoot(); + if (repositoryRoot == null) { + LOG.warn("This does not seem to be a git repository (no .git folder found in {} or any parent directory).", this.context.getCwd()); + return; + } + + LOG.info("Found repository root at {}.", repositoryRoot); + checkGitIgnore(repositoryRoot, issues); + report(issues); + } + + private Path findRepositoryRoot() { + GitContext gitContext = this.context.getGitContext(); + return gitContext.findRepositoryRoot(this.context.getCwd()); + } + + private void checkGitIgnore(Path repositoryRoot, List issues) { + + Path gitignore = repositoryRoot.resolve(MissingGitignoreIssue.GITIGNORE); + if (!context.getFileAccess().isFile(gitignore)) { + issues.add(new MissingGitignoreIssue(repositoryRoot)); + return; + } + + List lines; + + lines = context.getFileAccess().readFileLines(gitignore); + + if (!containsRule(lines, RULE_IGNORE_HIDDEN)) { + issues.add(new MissingGitignoreRuleIssue(gitignore, RULE_IGNORE_HIDDEN)); + } + + if (!containsRule(lines, RULE_UNIGNORE_GITIGNORE)) { + issues.add(new MissingGitignoreRuleIssue(gitignore, RULE_UNIGNORE_GITIGNORE)); + } + } + + private boolean containsRule(List lines, String rule) { + + return lines.stream().anyMatch(line -> line.strip().equals(rule)); + } + + private void report(List issues) { + + if (issues.isEmpty()) { + IdeLogLevel.SUCCESS.log(LOG, "No issues found."); + return; + } + + LOG.warn("Found {} issue(s)", issues.size()); + + boolean open = false; + for (CheckIssue issue : issues) { + if (this.fix.isTrue() && issue.isFixable()) { + if (issue.fix(this.context)) { + IdeLogLevel.SUCCESS.log(LOG, "Fixed: {}", issue); + continue; + } + LOG.warn("Failed to fix {}", issue); + open = true; + } else { + LOG.warn("{}", issue); + open = true; + } + } + + if (open) { + throw new CliException("ide check found issues - see issues above.", 1); + } + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/CheckIssue.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/CheckIssue.java new file mode 100644 index 00000000..37e3c924 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/CheckIssue.java @@ -0,0 +1,92 @@ +package com.devonfw.tools.ide.commandlet.check; + +import java.nio.file.Path; + +import com.devonfw.tools.ide.context.IdeContext; + +/** + * Represents an issue found by {@code ide check}. + */ +public class CheckIssue { + + private final Path path; + + private final Integer line; + + private final String message; + + /** + * The constructor for an issue not tied to a specific line. + * + * @param path the {@link Path} to the affected file. May point to a file that does not exist. + * @param message a description of the problem. + */ + protected CheckIssue(Path path, String message) { + + this.path = path; + this.line = null; + this.message = message; + } + + /** + * The constructor. + * + * @param path the {@link Path} to the affected file. May point to a file that does not exist. + * @param line the line number within {@code path} that this issue refers to. + * @param message a description of the problem. + */ + protected CheckIssue(Path path, Integer line, String message) { + this.path = path; + this.line = line; + this.message = message; + } + + /** + * @return the {@link Path} to the affected file. + */ + public Path getPath() { + + return this.path; + } + + /** + * @return the line number this issue refers to, or {@code null} if not applicable. + */ + public Integer getLineNumber() { + + return this.line; + } + + /** + * @return a description of the problem. + */ + public String getMessage() { + + return this.message; + } + + /** + * @return {@code true} if this issue can be automatically fixed via {@link #fix(IdeContext)}, {@code false} otherwise. + */ + public boolean isFixable() { + return false; + } + + /** + * Attempts to fix the problem that caused this issue. + * + * @param context the {@link IdeContext}. + * @return {@code true} if the fix was applied successfully, {@code false} otherwise. + */ + public boolean fix(IdeContext context) { + return false; + } + + @Override + public String toString() { + if (this.line == null) { + return this.path + ": " + this.message; + } + return this.path + ":" + this.line + ": " + this.message; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/MissingGitignoreIssue.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/MissingGitignoreIssue.java new file mode 100644 index 00000000..1709ebfd --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/MissingGitignoreIssue.java @@ -0,0 +1,37 @@ +package com.devonfw.tools.ide.commandlet.check; + +import java.nio.file.Path; +import java.util.List; + +import com.devonfw.tools.ide.context.IdeContext; + +/** + * A {@link CheckIssue} reporting that the repository root has no {@code .gitignore} file. When fixed, creates + * a minimal {@code .gitignore} containing the rules ({@code .*} and {@code !.gitignore}). + */ +public class MissingGitignoreIssue extends CheckIssue { + + /** The file name of the git-ignore file. */ + public static final String GITIGNORE = ".gitignore"; + + /** + * The constructor. + * + * @param repositoryRoot the {@link Path} to the (missing) {@code .gitignore} file in the repository root. + */ + public MissingGitignoreIssue(Path repositoryRoot) { + super(repositoryRoot.resolve(GITIGNORE), "No " + GITIGNORE + " found in repository root."); + } + + @Override + public boolean isFixable() { + return true; + } + + @Override + public boolean fix(IdeContext context) { + + context.getFileAccess().writeFileLines(List.of(".*", "!" + GITIGNORE), getPath()); + return true; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/MissingGitignoreRuleIssue.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/MissingGitignoreRuleIssue.java new file mode 100644 index 00000000..3eea7061 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/MissingGitignoreRuleIssue.java @@ -0,0 +1,46 @@ +package com.devonfw.tools.ide.commandlet.check; + +import java.nio.file.Path; +import java.util.List; + +import com.devonfw.tools.ide.context.IdeContext; + +/** + * A {@link CheckIssue} reporting that a {@code .gitignore} file exists but is missing expected rules. When fixed, appends the missing rule as a new line at the + * end of the file. + */ +public class MissingGitignoreRuleIssue extends CheckIssue { + + private final String rule; + + /** + * The constructor. + * + * @param gitignore the {@link Path} to the existing {@code .gitignore} file. + * @param rule the missing rule (e.g. {@code ".*"} or {@code "!.gitignore"}). + */ + public MissingGitignoreRuleIssue(Path gitignore, String rule) { + super(gitignore, "Missing rule \"" + rule + "\" in " + gitignore.getFileName() + "."); + this.rule = rule; + } + + @Override + public boolean isFixable() { + + return true; + } + + @Override + public boolean fix(IdeContext context) { + + List lines = context.getFileAccess().readFileLines(getPath()); + + if (lines == null) { + return false; + } + + lines.add(this.rule); + context.getFileAccess().writeFileLines(lines, getPath()); + return true; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/git/GitContext.java b/cli/src/main/java/com/devonfw/tools/ide/git/GitContext.java index e77957d2..3d167b85 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/git/GitContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/git/GitContext.java @@ -320,4 +320,12 @@ default void push(Path repository) { * @throws IllegalStateException if the remote already exists with a different URL. */ void addRemoteOrFail(Path repository, String name, String url); + + /** + * Find the root of the git repository + * + * @param repository the {@link Path} to the git repository. + * @return the {@link Path} to the repository root + */ + Path findRepositoryRoot(Path repository); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/git/GitContextImpl.java b/cli/src/main/java/com/devonfw/tools/ide/git/GitContextImpl.java index 49adbb59..553fcab5 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/git/GitContextImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/git/GitContextImpl.java @@ -606,4 +606,20 @@ public void push(Path repository, boolean followTags) { runGitCommand(repository, "push"); } } + + @Override + public Path findRepositoryRoot(Path directory) { + if (directory == null) { + return null; + } + + Path dir = this.context.getFileAccess().toRealPath(directory); + while (dir != null) { + if (isGitRepo(dir)) { + return dir; + } + dir = dir.getParent(); + } + return null; + } } diff --git a/cli/src/main/resources/nls/Help.properties b/cli/src/main/resources/nls/Help.properties index ba8b7c72..ee39e7a3 100644 --- a/cli/src/main/resources/nls/Help.properties +++ b/cli/src/main/resources/nls/Help.properties @@ -9,6 +9,9 @@ cmd.build.detail=The `build` commandlet is an abstraction of build systems like cmd.build.val.args=Build arguments to be used when running a build job. cmd.cdk=Tool commandlet for AWS CDK. cmd.cdk.detail=The AWS Cloud Development Kit (AWS CDK) is an open-source software development framework for defining cloud infrastructure in code and provisioning it through AWS CloudFormation. Detailed documentation can be found at https://docs.aws.amazon.com/cdk/v2/guide/home.html +cmd.check=Checks the repository for best-practices. +cmd.check.detail=Verifies that your current repository is sane and follows common best-practices avoiding anti-patterns. Issues can be fixed using the '--fix' option. +cmd.check.opt.--fix=Fixes issues where a fix is available. cmd.claude=Tool commandlet for Claude Code CLI. cmd.claude.detail=Claude Code CLI is a command-line interface for interacting with the Claude AI assistant. Detailed documentation can be found at https://code.claude.com/docs/en/overview cmd.cleanup=Commandlet to clean up the IDEasy installation by uninstalling all unused tools. diff --git a/cli/src/main/resources/nls/Help_de.properties b/cli/src/main/resources/nls/Help_de.properties index 07b31572..410374e8 100644 --- a/cli/src/main/resources/nls/Help_de.properties +++ b/cli/src/main/resources/nls/Help_de.properties @@ -9,6 +9,9 @@ cmd.build.detail=Der `build`-Befehl ist eine Abstraktion von Build-Systemen wie cmd.build.val.args=Argumente die bei dem build Prozess genutzt werden sollen. cmd.cdk=Werkzeug Kommando für AWS CDK. cmd.cdk.detail=Das AWS Cloud Development Kit (AWS CDK) ist ein Open-Source-Softwareentwicklungsframework zur Modellierung und Bereitstellung von Cloud-Anwendungen mithilfe von bekannten Programmiersprachen. Detaillierte Dokumentation ist zu finden unter https://docs.aws.amazon.com/cdk/v2/guide/home.html +cmd.check=Prüft das Repository auf Best-Practices. +cmd.check.detail=Prüft, ob im das aktuelle Git-Repository plausibel ist und allgemeine Best-Practices einhält sowie Anti-Patterns vermeidet. Gefundene Probleme können mit '--fix' automatisch behoben werden. +cmd.check.opt.--fix=Behebt erkannte Probleme automatisch, sofern möglich. cmd.claude=Werkzeug Kommando für Claude Code CLI. cmd.claude.detail=Claude Code CLI ist ein KI-gestützter Programmierassistent, der über die Befehlszeile ausgeführt wird. Detaillierte Dokumentation ist zu finden unter https://code.claude.com/docs/de/overview cmd.cleanup=Werkzeug zum Aufräumen der IDEasy-Installation durch Deinstallieren aller ungenutzten Werkzeuge. diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/CheckCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/CheckCommandletTest.java new file mode 100644 index 00000000..798d6622 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/CheckCommandletTest.java @@ -0,0 +1,125 @@ +package com.devonfw.tools.ide.commandlet; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.devonfw.tools.ide.cli.CliException; +import com.devonfw.tools.ide.commandlet.check.CheckCommandlet; +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.log.IdeLogEntry; + +/** + * Test of {@link CheckCommandlet} + */ +public class CheckCommandletTest extends AbstractIdeContextTest { + + /** + * Test that {@code ide check} does not fail if the CWD is not inside a git repository. + */ + @Test + void testCheckWithoutGitRepoOnlyWarns(@TempDir Path tempDir) { + + IdeTestContext context = newContext(tempDir); + CheckCommandlet check = new CheckCommandlet(context); + + check.run(); + + assertThat(context).log().hasEntries( + IdeLogEntry.ofWarning("This does not seem to be a git repository (no .git folder found in " + tempDir + " or any parent directory).") + ); + } + + /** + * Test that {@code ide check} reports a missing .gitignore and fails (exit code 1) without --fix. + */ + @Test + void testCheckMissingGitignoreWithoutFix(@TempDir Path tempDir) throws IOException { + + Files.createDirectory(tempDir.resolve(".git")); + IdeTestContext context = newContext(tempDir); + CheckCommandlet check = new CheckCommandlet(context); + + assertThrows(CliException.class, check::run); + Path gitignore = tempDir.resolve(".gitignore"); + assertThat(gitignore).doesNotExist(); + assertThat(context).log().hasEntries(IdeLogEntry.ofWarning(gitignore + ": No .gitignore found in repository root.")); + } + + /** + * Test that {@code ide check --fix} creates a missing .gitignore with rules. + */ + @Test + void testCheckMissingGitignoreWithFix(@TempDir Path tempDir) throws IOException { + + Files.createDirectory(tempDir.resolve(".git")); + IdeTestContext context = newContext(tempDir); + CheckCommandlet check = new CheckCommandlet(context); + check.fix.setValue(true); + + check.run(); + + Path gitignore = tempDir.resolve(".gitignore"); + assertThat(gitignore).exists(); + List lines = Files.readAllLines(gitignore); + assertThat(lines).contains(".*", "!.gitignore"); + } + + /** + * Test that {@code ide check} detects missing rules in an existing .gitignore and fails without --fix. + */ + @Test + void testCheckExistingGitignoreMissingRulesWithoutFix(@TempDir Path tempDir) throws IOException { + + Files.createDirectory(tempDir.resolve(".git")); + Files.writeString(tempDir.resolve(".gitignore"), "target/\n"); + IdeTestContext context = newContext(tempDir); + CheckCommandlet check = context.getCommandletManager().getCommandlet(CheckCommandlet.class); + + assertThrows(CliException.class, check::run); + List lines = Files.readAllLines(tempDir.resolve(".gitignore")); + assertThat(lines).containsExactly("target/"); + } + + /** + * Test that {@code ide check --fix} appends missing rules to an existing .gitignore. + */ + @Test + void testCheckExistingGitignoreMissingRulesWithFix(@TempDir Path tempDir) throws IOException { + + Files.createDirectory(tempDir.resolve(".git")); + Files.writeString(tempDir.resolve(".gitignore"), "target/\n"); + IdeTestContext context = newContext(tempDir); + CheckCommandlet check = context.getCommandletManager().getCommandlet(CheckCommandlet.class); + check.fix.setValue(true); + + check.run(); + + List lines = Files.readAllLines(tempDir.resolve(".gitignore")); + assertThat(lines).contains("target/", ".*", "!.gitignore"); + } + + /** + * Test that {@code ide check} passes without issues if the .gitignore already has both baseline rule + */ + @Test + void testCheckPassesWhenGitignoreAlreadyValid(@TempDir Path tempDir) throws IOException { + + Files.createDirectory(tempDir.resolve(".git")); + Files.writeString(tempDir.resolve(".gitignore"), ".*\n!.gitignore\n"); + IdeTestContext context = newContext(tempDir); + CheckCommandlet check = context.getCommandletManager().getCommandlet(CheckCommandlet.class); + check.fix.setValue(true); + + check.run(); + + assertThat(context).log().hasEntries(IdeLogEntry.ofSuccess("No issues found.")); + } +} From e224e69ace7b3f0a65daf8f23b157649aff2e1f1 Mon Sep 17 00:00:00 2001 From: QuangAnhLe Date: Mon, 21 Sep 2026 10:13:16 +0200 Subject: [PATCH 11/66] Doccument for Run GitHub Actions Locally with act (#2435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jörg Hohwiller --- .../contributing/workflow-testing.adoc | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 documentation/contributing/workflow-testing.adoc diff --git a/documentation/contributing/workflow-testing.adoc b/documentation/contributing/workflow-testing.adoc new file mode 100644 index 00000000..4ea63a07 --- /dev/null +++ b/documentation/contributing/workflow-testing.adoc @@ -0,0 +1,231 @@ += Run GitHub Actions Locally with act +:toc: +toc::[] + +https://github.com/nektos/act[act] allows contributors to run GitHub Actions workflows locally. +This can be useful for testing workflow changes before pushing them to GitHub. + +NOTE: Running a workflow locally with `act` does not guarantee that it will behave exactly like a workflow running on GitHub-hosted runners. + +== Prerequisites + +To run workflows locally, the following tools are required: + +* https://github.com/nektos/act[act] +* A running Docker-compatible container engine +* A local clone of the IDEasy repository + +The commands in this guide must be executed from the root directory of the IDEasy repository, where the `.github/workflows` directory is located. + +== Install act on Windows + +There are multiple ways to install `act` on Windows. +One option is using https://scoop.sh/[Scoop]: + +[source,bash] +---- +scoop install act +---- + +Verify the installation: + +[source,bash] +---- +act --version +---- + +For other installation options, see the https://nektosact.com/installation/index.html[official act installation documentation]. + +== Set Up Docker + +`act` executes workflow jobs inside containers. +Therefore, a running Docker-compatible container engine is required. + +IDEasy recommends https://rancherdesktop.io/[Rancher Desktop] as an alternative to Docker Desktop. +See link:../docker-desktop-alternative.adoc[Docker Desktop alternative] for additional information. + +After starting Rancher Desktop, verify that Docker is available: + +[source,bash] +---- +docker version +docker ps +---- + +=== Configure the Docker Host + +By default, `act` expects the Docker Engine provided by Docker Desktop. +When Rancher Desktop is used, it may be necessary to set `DOCKER_HOST`. + +In Git Bash: + +[source,bash] +---- +export DOCKER_HOST=$(docker context inspect --format '{{.Endpoints.docker.Host}}') +---- + +Verify the configured value: + +[source,bash] +---- +echo "$DOCKER_HOST" +---- + +For the current PowerShell session: + +[source,powershell] +---- +$env:DOCKER_HOST = docker context inspect --format "{{.Endpoints.docker.Host}}" +---- + +NOTE: The environment variable configured with these commands applies only to the current terminal session. + +== List Available Workflows and Jobs + +From the root directory of the IDEasy repository, list the workflows and jobs detected by `act`: + +[source,bash] +---- +act --list +---- + +This command reads the workflow definitions from `.github/workflows`. + +== Run a Specific Workflow + +Use `-W` to select a workflow file. + +For example: + +[source,bash] +---- +act -W .github/workflows/build.yml +---- + +The workflow path is relative to the root directory of the repository. + +== Select a Runner Image + +GitHub workflow jobs define a runner platform such as `ubuntu-latest`. +With `act`, the `-P` option maps that platform to a container image. + +The following command runs `build.yml` using the full Ubuntu runner image: + +[source,bash] +---- +act \ + -P ubuntu-latest=catthehacker/ubuntu:full-latest \ + -W .github/workflows/build.yml +---- + +On PowerShell, the same command can be written as: + +[source,powershell] +---- +act ` + -P ubuntu-latest=catthehacker/ubuntu:full-latest ` + -W .github/workflows/build.yml +---- + +The `catthehacker/ubuntu:full-latest` image is large and requires significant disk space. +The first execution downloads the image before starting the workflow. + +Smaller runner images may require less storage, but they may not contain all tools required by the IDEasy build. + +For information about available runner images and platform mappings, see the https://nektosact.com/usage/runners.html[act runner documentation]. + +== Provide GitHub Secrets + +GitHub Actions workflows may use secrets that are available on GitHub but not on the local machine. + +A secret can be provided using `-s`: + +[source,bash] +---- +act -s SECRET_NAME=SECRET_VALUE +---- + +To avoid putting a secret directly into the shell history, omit its value: + +[source,bash] +---- +act -s SECRET_NAME +---- + +`act` will then request the value interactively. + +WARNING: Never commit tokens, passwords, or other credentials to the repository. + +== Known Limitations + +=== JmcTest.testJmcRun Fails + +`JmcTest.testJmcRun` may fail when executed with `act`. + +=== Missing COVERALLS_REPO_TOKEN + +After the build has completed, the Coveralls step may fail with: + +[source,text] +---- +Missing COVERALLS_REPO_TOKEN +---- + +The workflow expects the `COVERALLS_REPO_TOKEN` secret, which is not automatically available during a local run. + +If access to an appropriate token is available, it can be passed to `act`: + +[source,bash] +---- +act \ + -s COVERALLS_REPO_TOKEN \ + -P ubuntu-latest=catthehacker/ubuntu:full-latest \ + -W .github/workflows/build.yml +---- + +Do not put the actual token into this documentation or commit it to Git. + +=== No Stages Found for nightly-build.yml + +Although the jobs in `nightly-build.yml` may appear in the output of `act --list`, running the workflow can produce: + +[source,text] +---- +Error: Could not find any stages to run +---- + +The jobs may depend on GitHub event data and job-level `if` conditions that are not satisfied during the local execution. + +For diagnostic purposes, contributors can inspect: + +* The events configured below `on:` +* Job-level `if:` expressions +* Dependencies declared with `needs:` + +Changing workflow triggers or removing conditions changes the behavior of the workflow. +Such changes should only be made in a local branch for investigation and must not be committed as a workaround without reviewing their effect on GitHub CI. + +== Clean Up Docker Resources + +List the downloaded images: + +[source,bash] +---- +docker images +---- + +Display Docker disk usage: + +[source,bash] +---- +docker system df +---- + +Remove an image if it is no longer required: + +[source,bash] +---- +docker image rm catthehacker/ubuntu:full-latest +---- + +The image cannot be removed while it is used by an existing container. From 4fee91a9c8ee125b3e30996cb9c82a6246c78115 Mon Sep 17 00:00:00 2001 From: devonfw-core Date: Mon, 21 Sep 2026 11:43:15 +0000 Subject: [PATCH 12/66] Update quality status documents --- .../functional-labels.svg | 54 ++++----- .../quality-status-images/issue-age.svg | 22 ++-- .../issue-assignment.svg | 10 +- .../quality-status-images/issue-types.svg | 18 +-- .../operating-systems.svg | 24 ++-- .../quality-status/quality-status-linux.adoc | 8 +- .../quality-status/quality-status-macos.adoc | 18 +-- .../quality-status-windows.adoc | 20 ++-- .../quality-status/quality-status.adoc | 113 +++++++++++++----- 9 files changed, 170 insertions(+), 117 deletions(-) diff --git a/documentation/quality-status/quality-status-images/functional-labels.svg b/documentation/quality-status/quality-status-images/functional-labels.svg index caff3821..7393fa21 100644 --- a/documentation/quality-status/quality-status-images/functional-labels.svg +++ b/documentation/quality-status/quality-status-images/functional-labels.svg @@ -23,41 +23,41 @@ GUI -46 -install +52 +internal - -21 -commandlet + +26 +install - -18 -internal + +25 +commandlet - -17 -settings + +21 +vscode - -12 + +14 AI - -11 -urls + +12 +settings - -11 -docker + +12 +intellij - -8 -software + +11 +urls - -8 -configuration + +11 +CLI - -7 + +10 diff --git a/documentation/quality-status/quality-status-images/issue-age.svg b/documentation/quality-status/quality-status-images/issue-age.svg index 92c35267..47784eed 100644 --- a/documentation/quality-status/quality-status-images/issue-age.svg +++ b/documentation/quality-status/quality-status-images/issue-age.svg @@ -20,22 +20,22 @@ Issue age Issue age - + -0-10 days: 35 (17.9%) - +0-10 days: 46 (21.4%) + -11-30 days: 33 (16.8%) - +11-30 days: 36 (16.7%) + -31-60 days: 36 (18.4%) - +31-60 days: 37 (17.2%) + -61-90 days: 12 (6.1%) - +61-90 days: 13 (6.0%) + -90+ days: 80 (40.8%) +90+ days: 83 (38.6%) -196 +215 total diff --git a/documentation/quality-status/quality-status-images/issue-assignment.svg b/documentation/quality-status/quality-status-images/issue-assignment.svg index 49cf95df..a21978cc 100644 --- a/documentation/quality-status/quality-status-images/issue-assignment.svg +++ b/documentation/quality-status/quality-status-images/issue-assignment.svg @@ -20,13 +20,13 @@ Assigned vs unassigned issues Assigned vs unassigned issues - + -Assigned: 128 (65.3%) - +Assigned: 139 (64.7%) + -Unassigned: 68 (34.7%) +Unassigned: 76 (35.3%) -196 +215 total diff --git a/documentation/quality-status/quality-status-images/issue-types.svg b/documentation/quality-status/quality-status-images/issue-types.svg index 61e9f2c8..798503ac 100644 --- a/documentation/quality-status/quality-status-images/issue-types.svg +++ b/documentation/quality-status/quality-status-images/issue-types.svg @@ -20,19 +20,19 @@ Issue type distribution Issue type distribution - + -Bugs: 38 (19.4%) - +Bugs: 40 (18.6%) + -Tasks: 70 (35.7%) - +Tasks: 79 (36.7%) + -Features: 82 (41.8%) - +Features: 84 (39.1%) + -No Type: 6 (3.1%) +No Type: 12 (5.6%) -196 +215 total diff --git a/documentation/quality-status/quality-status-images/operating-systems.svg b/documentation/quality-status/quality-status-images/operating-systems.svg index 7c8760ee..baf5b901 100644 --- a/documentation/quality-status/quality-status-images/operating-systems.svg +++ b/documentation/quality-status/quality-status-images/operating-systems.svg @@ -27,18 +27,18 @@ Cross-platform Windows - - - -174 + + + +195 Linux - - - -170 + + + +189 macOS - - - -178 + + + +195 diff --git a/documentation/quality-status/quality-status-linux.adoc b/documentation/quality-status/quality-status-linux.adoc index a8403181..f1e38af3 100644 --- a/documentation/quality-status/quality-status-linux.adoc +++ b/documentation/quality-status/quality-status-linux.adoc @@ -9,26 +9,24 @@ Automatically generated open issue overview for Linux. link:quality-status.adoc[Back to overview] -*Total Issues:* 170 +*Total Issues:* 189 * OS-specific: 6 * Multi-OS: 3 -* Cross-platform: 161 +* Cross-platform: 180 == Linux Specific Issues [%header, cols="^1,7"] |=== | Issue | Summary -2+^| *11-30 days* +2+^| *31-60 days* | link:https://github.com/devonfw/IDEasy/issues/2345[#2345] | DOCKER_EDITION ignored on Linux -2+^| *31-60 days* - | link:https://github.com/devonfw/IDEasy/issues/2271[#2271] | ide install pgadmin fails on Ubuntu Linux diff --git a/documentation/quality-status/quality-status-macos.adoc b/documentation/quality-status/quality-status-macos.adoc index 34f71d99..4e6c487b 100644 --- a/documentation/quality-status/quality-status-macos.adoc +++ b/documentation/quality-status/quality-status-macos.adoc @@ -9,13 +9,13 @@ Automatically generated open issue overview for macOS. link:quality-status.adoc[Back to overview] -*Total Issues:* 178 +*Total Issues:* 195 -* OS-specific: 14 +* OS-specific: 12 * Multi-OS: 3 -* Cross-platform: 161 +* Cross-platform: 180 == macOS Specific Issues @@ -25,21 +25,13 @@ link:quality-status.adoc[Back to overview] 2+^| *0-10 days* | link:https://github.com/devonfw/IDEasy/issues/2468[#2468] -| Global tool commandlets ignore configured edition for name/version detection (W… +| Global tool commandlets ignore edition for name/version detection (Windows regi… -2+^| *11-30 days* +2+^| *31-60 days* | link:https://github.com/devonfw/IDEasy/issues/2344[#2344] | Small cleanup: align macos pkg build script with windows msi build script -2+^| *31-60 days* - -| link:https://github.com/devonfw/IDEasy/issues/2316[#2316] -| Homebrew formula publish fails: brew audit no longer accepts a path - -| link:https://github.com/devonfw/IDEasy/issues/2252[#2252] -| Provide uninstall support for globally installed tools (macOS) - | link:https://github.com/devonfw/IDEasy/issues/2206[#2206] | IDEasy title not properly shown on macOS diff --git a/documentation/quality-status/quality-status-windows.adoc b/documentation/quality-status/quality-status-windows.adoc index 90a51bbb..45436185 100644 --- a/documentation/quality-status/quality-status-windows.adoc +++ b/documentation/quality-status/quality-status-windows.adoc @@ -9,13 +9,13 @@ Automatically generated open issue overview for Windows. link:quality-status.adoc[Back to overview] -*Total Issues:* 174 +*Total Issues:* 195 -* OS-specific: 12 +* OS-specific: 14 * Multi-OS: 1 -* Cross-platform: 161 +* Cross-platform: 180 == Windows Specific Issues @@ -24,25 +24,31 @@ link:quality-status.adoc[Back to overview] | Issue | Summary 2+^| *0-10 days* +| link:https://github.com/devonfw/IDEasy/issues/2473[#2473] +| Uninstalling IDEasy from "Programs & features"/"Apps" screen does not work on w… + +| link:https://github.com/devonfw/IDEasy/issues/2472[#2472] +| Multiple IDEasy entries in Windows Control Center + +2+^| *11-30 days* + | link:https://github.com/devonfw/IDEasy/issues/2442[#2442] | Add pom.xml to windows-installer and align its build with the maven-driven macO… | link:https://github.com/devonfw/IDEasy/issues/2431[#2431] | Improve uv support to make it usable for IDE_ROOT on non-default drive -2+^| *11-30 days* - | link:https://github.com/devonfw/IDEasy/issues/2406[#2406] | Prepare IDEasy for Microsoft Store App Submission | link:https://github.com/devonfw/IDEasy/issues/2405[#2405] | Set Up Microsoft Store Developer Account for IDEasy +2+^| *31-60 days* + | link:https://github.com/devonfw/IDEasy/issues/2342[#2342] | Display ProgressBar progress in windows taskbar -2+^| *31-60 days* - | link:https://github.com/devonfw/IDEasy/issues/2226[#2226] | Show IDEasy project, workspace and repository in the Git Bash / Windows Termina… diff --git a/documentation/quality-status/quality-status.adoc b/documentation/quality-status/quality-status.adoc index 98ab4556..c75ac2fc 100644 --- a/documentation/quality-status/quality-status.adoc +++ b/documentation/quality-status/quality-status.adoc @@ -20,7 +20,7 @@ image::quality-status-images/issue-assignment.svg[Pie chart: assigned vs unassig image::quality-status-images/issue-types.svg[Pie chart: issue type distribution,width=100%] -_Generated: 2026-09-14 11:29 UTC_ +_Generated: 2026-09-21 11:43 UTC_ == Operating System Status Files @@ -29,7 +29,7 @@ Issues are assigned to operating systems based on their labels: `windows`, `linux`, or `macOS`. Issues without an operating system label are treated as cross-platform. -*A total of 161 cross-platform issues* are documented centrally in this document and are therefore not repeated in the operating system specific files. +*A total of 180 cross-platform issues* are documented centrally in this document and are therefore not repeated in the operating system specific files. The detailed tool status is split into one generated file per operating system. @@ -58,6 +58,75 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | Issue | Summary 2+^a| [[cross-platform-0-10-days]] *0-10 days* +| link:https://github.com/devonfw/IDEasy/issues/2514[#2514] +| Add commandlet for Ollama CLI (`ollama`) + +| link:https://github.com/devonfw/IDEasy/issues/2513[#2513] +| Add commandlet for Ansible (`ansible` / `ansible-playbook`) + +| link:https://github.com/devonfw/IDEasy/issues/2512[#2512] +| Add commandlet for Pandoc (`pandoc`) + +| link:https://github.com/devonfw/IDEasy/issues/2511[#2511] +| Add commandlet for Helidon CLI (`helidon`) + +| link:https://github.com/devonfw/IDEasy/issues/2510[#2510] +| Add commandlet for Micronaut CLI (`mn`) + +| link:https://github.com/devonfw/IDEasy/issues/2509[#2509] +| VSCode user settings from the settings repository are ignored since user-data m… + +| link:https://github.com/devonfw/IDEasy/issues/2506[#2506] +| test title + +| link:https://github.com/devonfw/IDEasy/issues/2504[#2504] +| VSCode plugins cannot be reinstalled after the plugins folder was reset when VS… + +| link:https://github.com/devonfw/IDEasy/issues/2502[#2502] +| Replace global IdeStartContext lookup with ScopedValue + +| link:https://github.com/devonfw/IDEasy/issues/2495[#2495] +| Help text and documentation for the --project flag + +| link:https://github.com/devonfw/IDEasy/issues/2494[#2494] +| Retarget IDEA_PROPERTIES to the opened folder (two-phase wiring) + +| link:https://github.com/devonfw/IDEasy/issues/2493[#2493] +| Skip workspace configuration (merge + extra-SDK sync) for external launch paths + +| link:https://github.com/devonfw/IDEasy/issues/2492[#2492] +| Open the folder given by getOpenPath() in IntelliJ/VS Code launch args + +| link:https://github.com/devonfw/IDEasy/issues/2491[#2491] +| Add --project flag with path resolution/validation and getOpenPath() foundation + +| link:https://github.com/devonfw/IDEasy/issues/2486[#2486] +| Show little hint on greyed-out IDE start buttons + +| link:https://github.com/devonfw/IDEasy/issues/2485[#2485] +| Improve styling of tabs + +| link:https://github.com/devonfw/IDEasy/issues/2484[#2484] +| Tab Completion for `ide install python ` triggers uv installation/upda… + +| link:https://github.com/devonfw/IDEasy/issues/2483[#2483] +| Enable status sync between GUI Roadmap board and Project board + +| link:https://github.com/devonfw/IDEasy/issues/2482[#2482] +| Improve CSS styling implementation of the UI + +| link:https://github.com/devonfw/IDEasy/issues/2480[#2480] +| Rework Gui Tests + +| link:https://github.com/devonfw/IDEasy/issues/2478[#2478] +| Move variable resolution out of RepositoryProperties into the transformation to… + +| link:https://github.com/devonfw/IDEasy/issues/2477[#2477] +| CliAbortException should always be rethrown in a Step + +| link:https://github.com/devonfw/IDEasy/issues/2471[#2471] +| VSCode plugin installation fails with "Profile '' not found" when VSCODE_… + | link:https://github.com/devonfw/IDEasy/issues/2470[#2470] | Create a client service PoC for reading URL metadata @@ -112,6 +181,8 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2448[#2448] | Add Native AI Integration Support for IDEasy +2+^a| [[cross-platform-11-30-days]] *11-30 days* + | link:https://github.com/devonfw/IDEasy/issues/2440[#2440] | Add a possibility to open the IdeLauncher Tab when it's closed @@ -130,9 +201,6 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2427[#2427] | Improve update-cve workflow to detect and add OS specific CVEs to security.json… -| link:https://github.com/devonfw/IDEasy/issues/2426[#2426] -| Document how to run GitHub Actions locally with act - | link:https://github.com/devonfw/IDEasy/issues/2423[#2423] | isRepositoryUpdateAvailable is not covered by any test that reaches the compari… @@ -148,8 +216,6 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2417[#2417] | Analysis of OpenShell Nvidia for AI Agent -2+^a| [[cross-platform-11-30-days]] *11-30 days* - | link:https://github.com/devonfw/IDEasy/issues/2411[#2411] | Improve overview of projects which use IDEasy @@ -207,15 +273,14 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2369[#2369] | Rework openrewrite -| link:https://github.com/devonfw/IDEasy/issues/2366[#2366] -| Support for variables and expressions in repository URL - | link:https://github.com/devonfw/IDEasy/issues/2365[#2365] | Reduce plugin reinstallations | link:https://github.com/devonfw/IDEasy/issues/2364[#2364] | Error during installation of soapui +2+^a| [[cross-platform-31-60-days]] *31-60 days* + | link:https://github.com/devonfw/IDEasy/issues/2358[#2358] | Overhaul IDEasy project board review assigning @@ -228,14 +293,9 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2343[#2343] | Design: Progress bars & steps -| link:https://github.com/devonfw/IDEasy/issues/2338[#2338] -| Create first basic version of check commandlet only capable of checking for top… - | link:https://github.com/devonfw/IDEasy/issues/2337[#2337] | Analysis of IDEasy instance in docker -2+^a| [[cross-platform-31-60-days]] *31-60 days* - | link:https://github.com/devonfw/IDEasy/issues/2336[#2336] | Reconsider treating idea.properties as a user-configurable workspace template @@ -254,6 +314,9 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2314[#2314] | Implement the handling of Steps in the GUI. +| link:https://github.com/devonfw/IDEasy/issues/2293[#2293] +| Rework spyder as IdeToolCommandlet + | link:https://github.com/devonfw/IDEasy/issues/2283[#2283] | GUI blocks commandlets when license agreement is missing @@ -311,12 +374,11 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2214[#2214] | Overhaul language selection logic +2+^a| [[cross-platform-61-90-days]] *61-90 days* + | link:https://github.com/devonfw/IDEasy/issues/2200[#2200] | Add support to check repository for best-practices -| link:https://github.com/devonfw/IDEasy/issues/2193[#2193] -| Support for conditional auto-completion - | link:https://github.com/devonfw/IDEasy/issues/2191[#2191] | Improve auto-completion @@ -329,8 +391,6 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2182[#2182] | Basic structure for Commandlet view -2+^a| [[cross-platform-61-90-days]] *61-90 days* - | link:https://github.com/devonfw/IDEasy/issues/2142[#2142] | Move IDE-specific metada (.idea, .vscode) out of workspace @@ -349,15 +409,14 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2107[#2107] | Add documentation for contributing to the GUI -| link:https://github.com/devonfw/IDEasy/issues/2103[#2103] -| Add obsidian software/commandlet - | link:https://github.com/devonfw/IDEasy/issues/2097[#2097] | Configure IDE Plugins in Tool Configuration Tree in the GUI | link:https://github.com/devonfw/IDEasy/issues/2096[#2096] | Add a commandlet execution user interface +2+^a| [[cross-platform-90plus-days]] *90+ days* + | link:https://github.com/devonfw/IDEasy/issues/2058[#2058] | VSCode authentication not working with IDEasy @@ -367,8 +426,6 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2045[#2045] | Add tool selection and version/edition configuration via GUI -2+^a| [[cross-platform-90plus-days]] *90+ days* - | link:https://github.com/devonfw/IDEasy/issues/1996[#1996] | Revisit design for tools that do not install to software repository @@ -390,6 +447,9 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/1868[#1868] | Revision of the functionality of global tool commandlets +| link:https://github.com/devonfw/IDEasy/issues/1814[#1814] +| IDEasy still reports updates for settings are available after any number of inv… + | link:https://github.com/devonfw/IDEasy/issues/1808[#1808] | icd is not navigating into single available workspace on icd -p project @@ -399,9 +459,6 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/1785[#1785] | Implemenent confirmation modals using JavaFX -| link:https://github.com/devonfw/IDEasy/issues/1695[#1695] -| Clone settings to temporary directory, analyse, and then move - | link:https://github.com/devonfw/IDEasy/issues/1634[#1634] | UrlUpdater creates error entries for HTTP status 200 From 0a4c5277c32455112da5bdbb19cc95d19741c3d7 Mon Sep 17 00:00:00 2001 From: T9 <103292348+shodiBoy1@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:58:48 +0200 Subject: [PATCH 13/66] #377: Document Homebrew installation on MacOS (#2421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jörg Hohwiller --- CHANGELOG.adoc | 1 + documentation/setup.adoc | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 9dd8510d..a0d786fd 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -6,6 +6,7 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDE Release with new features and bugfixes: +* https://github.com/devonfw/IDEasy/issues/377[#377]: Document Homebrew installation on MacOS * https://github.com/devonfw/IDEasy/issues/2338[#2338]: Create first basic version of check commandlet * https://github.com/devonfw/IDEasy/issues/2193[#2193]: Added support for conditional auto-completion * https://github.com/devonfw/IDEasy/issues/2488[#2488]: Add compiler and SDK to MSVC installation diff --git a/documentation/setup.adoc b/documentation/setup.adoc index a6340fa8..3fc7e987 100644 --- a/documentation/setup.adoc +++ b/documentation/setup.adoc @@ -47,6 +47,22 @@ For any other existing terminal sessions, you must run the `source` command manu Once installed, you want to link:project.adoc[create a project]. As next step read the link:usage.adoc[usage]. +=== Homebrew (MacOS) + +If you have https://brew.sh/[Homebrew] installed, you can install IDEasy from the https://github.com/devonfw/homebrew-IDEasy[IDEasy tap]: + +```bash +brew install devonfw/ideasy/ideasy +``` + +To configure `IDE_ROOT` and register the `ide` and `icd` shell functions in `~/.bashrc` and `~/.zshrc`, run: + +```bash +ideasy install +``` + +After setup, open a new terminal to use `ide` and `icd`. + === Installation Logs The IDEasy installer writes installation logs to: From 19972e3686c8af17b0ea4a6d445f43807713e64d Mon Sep 17 00:00:00 2001 From: quando632 Date: Mon, 21 Sep 2026 16:16:17 +0200 Subject: [PATCH 14/66] #2477: always rethrow CliAbortException in a Step (#2501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jörg Hohwiller --- .../tools/ide/cli/CliAbortException.java | 10 ++++++- .../git/repository/RepositoryCommandlet.java | 14 +++------- .../commandlet/RepositoryCommandletTest.java | 17 ++++++++++++ .../com/devonfw/tools/ide/step/StepTest.java | 27 +++++++++++++++++++ 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/cli/src/main/java/com/devonfw/tools/ide/cli/CliAbortException.java b/cli/src/main/java/com/devonfw/tools/ide/cli/CliAbortException.java index ab8d5220..2acdf015 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/cli/CliAbortException.java +++ b/cli/src/main/java/com/devonfw/tools/ide/cli/CliAbortException.java @@ -3,7 +3,9 @@ import com.devonfw.tools.ide.process.ProcessResult; /** - * {@link CliException} that is thrown if the user aborted further processing due + * {@link CliException} that is thrown if the user aborted further processing (e.g. by answering a question with "no") or if a question cannot be asked because + * we are in batch mode without force mode. Since the end-user wants to stop, this exception {@link #isForceRethrowInStep() is always re-thrown} from a + * {@link com.devonfw.tools.ide.step.Step Step} so no further step is executed. */ public final class CliAbortException extends CliException { @@ -15,4 +17,10 @@ public CliAbortException() { super("Aborted by end-user.", ProcessResult.ABORT); } + @Override + public boolean isForceRethrowInStep() { + + return true; + } + } diff --git a/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryCommandlet.java index aabda177..6f2256ae 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryCommandlet.java @@ -12,7 +12,6 @@ import org.slf4j.LoggerFactory; import com.devonfw.tools.ide.cli.CliAbortException; -import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.commandlet.Commandlet; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.git.GitContext; @@ -93,7 +92,8 @@ protected void doRun() { /** * Like {@link #prepareActiveRepository(Path, boolean)} but ignores a single invalid repository instead of aborting the setup of all others. Resolving a * property value may fail for a malformed expression, and one broken file in the repositories folder must not prevent every other repository from being - * set up. Only used when iterating all repositories - if the user explicitly requested a single repository, the failure is reported instead. + * set up. Only used when iterating all repositories - if the user explicitly requested a single repository, the failure is reported instead. A + * {@link CliAbortException} is still propagated as the {@link Step} always re-throws it. * * @param repositoryFile the {@link Path} to the repository properties file. * @param forceMode - {@code true} to setup the repository even if it is not active, {@code false} otherwise. @@ -101,14 +101,8 @@ protected void doRun() { */ private RepositoryConfig prepareActiveRepositoryIgnoringInvalid(Path repositoryFile, boolean forceMode) { - try { - return prepareActiveRepository(repositoryFile, forceMode); - } catch (CliAbortException e) { - throw e; // the user deliberately aborted - never swallow this - } catch (CliException e) { - LOG.error("Ignoring repository {} because its configuration is invalid: {}", repositoryFile, e.getMessage()); - return null; - } + Step step = this.context.newStep("Prepare repository", repositoryFile); + return step.call(() -> prepareActiveRepository(repositoryFile, forceMode), () -> null); } private RepositoryConfig prepareActiveRepository(Path repositoryFile, boolean forceMode) { diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/RepositoryCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/RepositoryCommandletTest.java index 4a5b9bd6..2ec13f08 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/RepositoryCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/RepositoryCommandletTest.java @@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test; +import com.devonfw.tools.ide.cli.CliAbortException; import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.context.IdeTestContext; @@ -144,6 +145,22 @@ void testInvalidExpressionDoesNotPreventOtherRepositories() { assertThat(context).logAtError().hasMessageContaining("Invalid template expression"); } + @Test + void testAbortIsNotIgnoredWhenSettingUpAllRepositories() { + + // arrange + IdeTestContext context = newContext(IdeContext.FOLDER_REPOSITORY); + // in batch mode without force mode a question cannot be asked, so evaluating the active flag aborts + context.getStartContext().setBatchMode(true); + Properties properties = createDefaultProperties(); + properties.setProperty("active", "@ask-variable('', 'Setup this repository?')"); + saveProperties(context, properties); + RepositoryCommandlet rc = context.getCommandletManager().getCommandlet(RepositoryCommandlet.class); + // act & assert + assertThatThrownBy(rc::run).isInstanceOf(CliAbortException.class); + assertThat(context.getIdeHome().resolve(IdeContext.FOLDER_WORKSPACES).resolve(TEST_WORKSPACE).resolve(TEST_REPO)).doesNotExist(); + } + @Test void testCircularVariableDoesNotPreventOtherRepositories() { diff --git a/cli/src/test/java/com/devonfw/tools/ide/step/StepTest.java b/cli/src/test/java/com/devonfw/tools/ide/step/StepTest.java index 0df53ab1..9d1a0243 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/step/StepTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/step/StepTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; +import com.devonfw.tools.ide.cli.CliAbortException; import com.devonfw.tools.ide.cli.CliFatalException; import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeTestContext; @@ -195,4 +196,30 @@ void testCallRethrowsForcedError() { assertThat(fallbackUsed).isFalse(); } + @Test + void testRunRethrowsAbort() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, "project", false); + Step step = context.newStep("Test-Step"); + // act & assert + assertThatThrownBy(() -> step.run(() -> { + throw new CliAbortException(); + })).isInstanceOf(CliAbortException.class); + assertThat(step.isFailure()).isTrue(); + } + + @Test + void testCallRethrowsAbort() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, "project", false); + Step step = context.newStep("Test-Step"); + // act & assert + assertThatThrownBy(() -> step.call(() -> { + throw new CliAbortException(); + }, () -> "fallback")).isInstanceOf(CliAbortException.class); + assertThat(step.isFailure()).isTrue(); + } + } From a6809790c9ff0615b648a44ef114e5b411bc68c3 Mon Sep 17 00:00:00 2001 From: Kim Date: Mon, 21 Sep 2026 16:22:26 +0200 Subject: [PATCH 15/66] #2431: Configure uv cache directory from IDE_ROOT (#2498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jörg Hohwiller --- CHANGELOG.adoc | 1 + .../java/com/devonfw/tools/ide/variable/IdeVariables.java | 5 +++++ .../tools/ide/commandlet/EnvironmentCommandletTest.java | 2 ++ 3 files changed, 8 insertions(+) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index a0d786fd..17a935c7 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -19,6 +19,7 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/989[#989]: Allow expressions in template variable definitions * https://github.com/devonfw/IDEasy/issues/2252[#2252]: Add uninstall support for globally installed tools on macOS * https://github.com/devonfw/IDEasy/issues/1628[#1628]: Support for OS-specific CVEs +* https://github.com/devonfw/IDEasy/issues/2431[#2431]: Improve uv support to make it usable for IDE_ROOT on non-default drive The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/50?closed=1[milestone 2026.09.002]. diff --git a/cli/src/main/java/com/devonfw/tools/ide/variable/IdeVariables.java b/cli/src/main/java/com/devonfw/tools/ide/variable/IdeVariables.java index d7a81bf8..9c218040 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/variable/IdeVariables.java +++ b/cli/src/main/java/com/devonfw/tools/ide/variable/IdeVariables.java @@ -139,6 +139,10 @@ public interface IdeVariables { VariableDefinitionBoolean VSCODE_PROFILE_ENABLED = new VariableDefinitionBoolean("VSCODE_PROFILE_ENABLED", null, c -> Boolean.FALSE); + /** {@link VariableDefinition} for the uv redirected cache directory */ + VariableDefinitionPath UV_CACHE_DIR = new VariableDefinitionPath("UV_CACHE_DIR", null, + c -> c.getIdePath() == null ? null : c.getIdePath().resolve(IdeContext.FOLDER_SOFTWARE).resolve("uv"), true, true); + /** A {@link Collection} with all pre-defined {@link VariableDefinition}s. */ Collection> VARIABLES = List.of(PATH, HOME, USER, WORKSPACE_PATH, IDE_HOME, IDE_ROOT, WORKSPACE, IDE_TOOLS, HTTP_VERSIONS, CREATE_START_SCRIPTS, @@ -146,6 +150,7 @@ public interface IdeVariables { GRADLE_USER_HOME, YARN_BUILD_OPTS, JASYPT_OPTS, MAVEN_ARGS, INTELLIJ_VM_ARGS, ANDROID_STUDIO_VM_ARGS, PYCHARM_VM_ARGS, + UV_CACHE_DIR, PROJECT_NAME, IDE_VARIABLE_SYNTAX_LEGACY_SUPPORT_ENABLED, PREFERRED_GIT_PROTOCOL); /** diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandletTest.java index 75b27cdb..6fc49aa5 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandletTest.java @@ -80,6 +80,7 @@ void testRunDebugLogging() { new IdeLogEntry(IdeLogLevel.PROCESSABLE, "export MAVEN_ARGS=\"-s ", true), // IdeLogEntry.ofProcessable("export NPM_CONFIG_USERCONFIG=\"" + normalize(context.getNpmConfigUserConfig()) + "\""), // new IdeLogEntry(IdeLogLevel.PROCESSABLE, "export PATH=", true), // + IdeLogEntry.ofProcessable("export UV_CACHE_DIR=\"" + normalize(context.getIdeRoot().resolve("_ide/software/uv")) + "\""), // IdeLogEntry.ofProcessable("WORKSPACE=\"foo-test\""), // IdeLogEntry.ofProcessable("WORKSPACE_PATH=\"" + normalize(context.getWorkspacePath()) + "\""), // @@ -141,6 +142,7 @@ void testRunInfoLogging() { IdeLogEntry.ofProcessable("TEST_ARGSb=\"user10 workspace10 settingsb user1 settings1 workspace1 conf1 user3 workspace3 confa userb\""), // IdeLogEntry.ofProcessable("TEST_ARGSc=\" user1 settings1 workspace1 conf1 userc settingsc confc\""), // IdeLogEntry.ofProcessable("TEST_ARGSd=\" user1 settings1 workspace1 conf1 userd workspaced\""), // + IdeLogEntry.ofProcessable("export UV_CACHE_DIR=\"" + normalize(context.getIdeRoot().resolve("_ide/software/uv")) + "\""), // IdeLogEntry.ofProcessable("WORKSPACE=\"foo-test\""), // IdeLogEntry.ofProcessable("WORKSPACE_PATH=\"" + normalize(context.getWorkspacePath()) + "\"") // ); From aeb52345a2a16c09048bb7ef2025000807ab370a Mon Sep 17 00:00:00 2001 From: Lyon <131815106+lyobrown@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:12:38 +0200 Subject: [PATCH 16/66] #1808: Clarify the selected workspace in ide and icd messages (#2446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: JoelAdbu Co-authored-by: Jörg Hohwiller --- CHANGELOG.adoc | 1 + .../tools/ide/context/AbstractIdeContext.java | 6 +- cli/src/main/package/bin/ide.bat | 7 ++- cli/src/main/package/functions | 6 +- cli/src/main/package/functions.ps1 | 8 ++- cli/src/test/functions-test.sh | 55 +++++++++++++++++++ documentation/cli.adoc | 8 +-- documentation/features.adoc | 4 +- 8 files changed, 85 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 17a935c7..fff274cd 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -17,6 +17,7 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/2142[#2142]: Move IDE-specific metadata (.idea, .vscode) out of workspace * https://github.com/devonfw/IDEasy/issues/2366[#2366]: Support for variables and expressions in repository properties * https://github.com/devonfw/IDEasy/issues/989[#989]: Allow expressions in template variable definitions +* https://github.com/devonfw/IDEasy/issues/1808[#1808]: Clarify the selected workspace in `ide` and `icd` messages * https://github.com/devonfw/IDEasy/issues/2252[#2252]: Add uninstall support for globally installed tools on macOS * https://github.com/devonfw/IDEasy/issues/1628[#1628]: Support for OS-specific CVEs * https://github.com/devonfw/IDEasy/issues/2431[#2431]: Improve uv support to make it usable for IDE_ROOT on non-default drive diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java index 4f514f65..ec076f07 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java @@ -441,7 +441,11 @@ private String getMessageIdeHomeFound() { if (isPrivacyMode() && !WORKSPACE_MAIN.equals(wks)) { wks = "*".repeat(wks.length()); } - return "IDE environment variables have been set for " + formatArgument(this.ideHome) + " in workspace " + wks; + String workspaceMessage = " with workspace set to " + wks; + if (!this.cwd.startsWith(this.workspacePath)) { + workspaceMessage += " (fallback to default)"; + } + return "IDE environment variables have been set for " + formatArgument(this.ideHome) + workspaceMessage; } private String getMessageNotInsideIdeProject() { diff --git a/cli/src/main/package/bin/ide.bat b/cli/src/main/package/bin/ide.bat index 00a95cc1..b83228cb 100644 --- a/cli/src/main/package/bin/ide.bat +++ b/cli/src/main/package/bin/ide.bat @@ -45,7 +45,12 @@ ideasy %IDE_OPTIONS% env >nul if %ERRORLEVEL% EQU 0 ( if "%~1" == "" ( - echo IDE environment variables have been set for %IDE_HOME% in workspace %WORKSPACE% + setlocal EnableDelayedExpansion + set "_workspaceMessage= with workspace set to !WORKSPACE!" + if not defined WORKSPACE_PATH set "_workspaceMessage=!_workspaceMessage! (fallback to default)" + if defined WORKSPACE_PATH if "!CD:%WORKSPACE_PATH%=!" == "!CD!" set "_workspaceMessage=!_workspaceMessage! (fallback to default)" + echo IDE environment variables have been set for !IDE_HOME!!_workspaceMessage! + endlocal ) ) diff --git a/cli/src/main/package/functions b/cli/src/main/package/functions index 266000ca..3fa7b8a1 100644 --- a/cli/src/main/package/functions +++ b/cli/src/main/package/functions @@ -43,7 +43,11 @@ function ide() { if [ $? = 0 ]; then eval "${ide_env}" if [ $# = 0 ] && [ -n "${IDE_HOME}" ]; then - echo "IDE environment variables have been set for ${IDE_HOME} in workspace ${WORKSPACE}" + local workspace_message=" with workspace set to ${WORKSPACE}" + if [ -z "${WORKSPACE_PATH}" ] || [ "${PWD#"${WORKSPACE_PATH}"}" = "${PWD}" ]; then + workspace_message="${workspace_message} (fallback to default)" + fi + echo "IDE environment variables have been set for ${IDE_HOME}${workspace_message}" fi fi if _ide_is_cygwin && [ "$#" != 0 ]; then diff --git a/cli/src/main/package/functions.ps1 b/cli/src/main/package/functions.ps1 index 251f3a72..9050a1cd 100644 --- a/cli/src/main/package/functions.ps1 +++ b/cli/src/main/package/functions.ps1 @@ -98,7 +98,13 @@ function ide { if ($ideArguments.Count -eq 0 -and -not [string]::IsNullOrEmpty($env:IDE_HOME)) { - Write-Host "IDE environment variables have been set for $env:IDE_HOME in workspace $env:WORKSPACE" + $workspaceMessage = " with workspace set to $env:WORKSPACE" + $currentPath = (Get-Location).Path.TrimEnd('\', '/') + $workspacePath = $env:WORKSPACE_PATH.TrimEnd('\', '/') + if (-not $currentPath.StartsWith($workspacePath, [System.StringComparison]::OrdinalIgnoreCase)) { + $workspaceMessage += " (fallback to default)" + } + Write-Host "IDE environment variables have been set for $env:IDE_HOME$workspaceMessage" } } } diff --git a/cli/src/test/functions-test.sh b/cli/src/test/functions-test.sh index 2a671016..911fc8ba 100644 --- a/cli/src/test/functions-test.sh +++ b/cli/src/test/functions-test.sh @@ -21,6 +21,23 @@ trap 'rm -rf "${STUB_DIR}"' EXIT # minimal fake "ideasy" binary so sourcing the functions file has no side effects cat > "${STUB_DIR}/ideasy" <<'EOS' #!/usr/bin/env bash +if [[ " $* " == *" env "* ]]; then + if [ -n "${STUB_IDE_HOME:-}" ]; then + workspace="main" + workspace_path="${STUB_IDE_HOME}/workspaces/main" + case "${PWD}" in + "${STUB_IDE_HOME}"/workspaces/*) + workspace_prefix="${STUB_IDE_HOME}/workspaces/" + workspace="${PWD#${workspace_prefix}}" + workspace="${workspace%%/*}" + workspace_path="${STUB_IDE_HOME}/workspaces/${workspace}" + ;; + esac + echo "IDE_HOME=${STUB_IDE_HOME}" + echo "WORKSPACE=${workspace}" + echo "WORKSPACE_PATH=${workspace_path}" + fi +fi exit 0 EOS chmod +x "${STUB_DIR}/ideasy" @@ -74,6 +91,44 @@ check "legacy Git Bash (OSTYPE=msys, uname=MINGW) shows no warning" hidden msys # MSYS environment -> no warning check "MSYS environment shows no warning" hidden cygwin "MSYS_NT-10.0-26200" +echo +echo "Testing workspace fallback messages in ${FUNCTIONS_FILE}" + +MESSAGE_HOME="${STUB_DIR}/message-project" +mkdir -p "${MESSAGE_HOME}/workspaces/main" "${MESSAGE_HOME}/workspaces/foo" "${MESSAGE_HOME}/settings" + +runIdeMessage() { + local directory="$1" + STUB_IDE_HOME="${MESSAGE_HOME}" MESSAGE_DIRECTORY="${directory}" \ + FUNCTIONS_FILE="${FUNCTIONS_FILE}" STUB_DIR="${STUB_DIR}" \ + bash --noprofile --norc -c ' + export PATH="${STUB_DIR}:${PATH}" + source "${FUNCTIONS_FILE}" >/dev/null 2>&1 + cd "${MESSAGE_DIRECTORY}" || exit 99 + ide + ' +} + +checkMessage() { + local description="$1" directory="$2" expected="$3" + total=$((total + 1)) + local output + output="$(runIdeMessage "${directory}")" + if [[ "${output}" == *"${expected}"* ]]; then + doSuccess "PASSED: ${description}" + else + doError "FAILED: ${description} - expected '${expected}' but was '${output}'" + failed=$((failed + 1)) + fi +} + +checkMessage "project root marks the default workspace as fallback" "${MESSAGE_HOME}" \ + "with workspace set to main (fallback to default)" +checkMessage "main workspace does not mark a fallback" "${MESSAGE_HOME}/workspaces/main" \ + "with workspace set to main" +checkMessage "named workspace does not mark a fallback" "${MESSAGE_HOME}/workspaces/foo" \ + "with workspace set to foo" + echo echo "Testing icd -r repository navigation in ${FUNCTIONS_FILE}" diff --git a/documentation/cli.adoc b/documentation/cli.adoc index 295c1ec3..05d726f6 100644 --- a/documentation/cli.adoc +++ b/documentation/cli.adoc @@ -18,7 +18,7 @@ In case you are not inside of a `IDEasy` folder the command will echo a message You are not inside an IDEasy installation: / [~]$ cd projects/my-project/workspaces/test/my-git-repo [my-git-repo]$ ide -IDEasy environment variables have been set for /projects/my-project in workspace main +IDEasy environment variables have been set for /projects/my-project with workspace set to test [my-git-repo]$ echo $IDE_HOME ~/projects/my-project [my-git-repo]$ echo $JAVA_HOME @@ -129,11 +129,11 @@ IDEasy also ships with the `icd` command that can be used as fancy improved vers ```bash [/tmp]$ icd -p myproject -w myworkspace -IDE environment variables have been set for /~/projects/myproject in workspace myworkspace +IDE environment variables have been set for /~/projects/myproject with workspace set to myworkspace [~/projects/myproject/workspaces/myworkspace]$ cd myrepo/mymodule/src/main/java/com/foo/bar/some/path [.../some/path]$ echo "do something here but now I want to go back to my project root dir" [.../some/path]$ icd -IDE environment variables have been set for /~/projects/myproject in workspace main +IDE environment variables have been set for /~/projects/myproject with workspace set to main (fallback to default) [~/projects/myproject]$ echo "Wow! IDEasy is awesome!" ``` @@ -144,7 +144,7 @@ If the repository is given explicitly but not found in any workspace, `icd` fail ```bash [/tmp]$ icd -p myproject -r myrepo -IDE environment variables have been set for /~/projects/myproject in workspace main +IDE environment variables have been set for /~/projects/myproject with workspace set to main [~/projects/myproject/workspaces/main/myrepo]$ echo "Found and jumped straight into myrepo!" ``` diff --git a/documentation/features.adoc b/documentation/features.adoc index 46d5eb7b..915c8212 100644 --- a/documentation/features.adoc +++ b/documentation/features.adoc @@ -66,7 +66,7 @@ Here are some examples with `IDEasy`: [cool-project]$ mvn command not found: mvn [cool-project]$ ide -Environment variables have been set for ~/projects/cool-project in workspace main +Environment variables have been set for ~/projects/cool-project with workspace set to main (fallback to default) [devonfw]$ mvn -v Apache Maven 3.9.9 (8e8579a9e76f7d015ee5ec7bfcdc97d260186937) Maven home: ~/projects/cool-project/software/mvn @@ -75,7 +75,7 @@ Default locale: de_DE, platform encoding: UTF-8 OS name: "mac os x", version: "21.0.4", arch: "x86_64", family: "mac" [cool-project]$ cd ../ide-test/workspaces/test/my-project [my-project]$ ide -Environment variables have been set for ~/projects/ide-test in workspace test +Environment variables have been set for ~/projects/ide-test with workspace set to test [my-project]$ mvn -v Apache Maven 3.6.0 (97c98ec64a1fdfee7767ce5ffb20918da4f719f3; 2018-10-24T20:41:47+02:00) Maven home: ~/projects/ide-test/software/mvn From 16c11610c0e7ddbc60572b2e18ec8a1d5f21b3ed Mon Sep 17 00:00:00 2001 From: JoelAdbu Date: Mon, 21 Sep 2026 18:25:33 +0200 Subject: [PATCH 17/66] #2422: cleanup determine installed edition and version and restore software version file (#2438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: quando632 Co-authored-by: Jörg Hohwiller --- .../tools/ide/tool/LocalToolCommandlet.java | 159 +++++++---------- .../devonfw/tools/ide/tool/python/Python.java | 35 ++-- .../ide/tool/LocalToolCommandletTest.java | 167 +++++++++++++++++- .../tools/ide/tool/python/PythonTest.java | 2 +- 4 files changed, 246 insertions(+), 117 deletions(-) diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java index f1cbbece..6d2a18a8 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java @@ -17,7 +17,6 @@ import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.process.ProcessContext; import com.devonfw.tools.ide.step.Step; -import com.devonfw.tools.ide.tool.repository.ToolRepository; import com.devonfw.tools.ide.url.model.file.json.ToolDependency; import com.devonfw.tools.ide.version.GenericVersionRange; import com.devonfw.tools.ide.version.VersionIdentifier; @@ -207,17 +206,12 @@ public ToolInstallation installTool(ToolInstallRequest request) { LOG.warn("Deleting corrupted installation at {}", installationPath); fileAccess.delete(installationPath); } else { - // version file is missing but tool allows this - restore the file and preserve the installation - VersionIdentifier installedVersion = getInstalledVersion(installationPath); - if (installedVersion == null) { - installedVersion = resolvedVersion; - } - restoreMissingVersionFile(installationPath, installedVersion); - if (installedVersion.equals(resolvedVersion)) { - return createToolInstallation(installationPath, installedVersion, false, processContext, additionalInstallation); - } - // the installation on disk is a different version than requested so we continue with the regular installation - // that will backup the existing installation before installing the requested version. + // the version file is missing but the tool allows this (e.g. because the version is determined from the installation itself). If + // the installed version could be determined, it was already restored to the version file during the determination of the installed + // edition and version (see computeInstalledEditionAndVersionFromLocalSoftwareFolder) and the installation above is kept. Reaching + // this point means the installed version could not be determined, so the installation is treated as broken and reinstalled. + LOG.warn("Could not determine the installed version of {} at {}, the installation is considered broken and will be reinstalled.", this.tool, + installationPath); } } } @@ -371,19 +365,6 @@ protected void postExtract(Path extractedDir) { } - @Override - protected ToolInstallation toolAlreadyInstalled(ToolInstallRequest request) { - - if (isIgnoreMissingSoftwareVersionFile()) { - // the installed version was determined from the installation itself so we can heal the missing version file - ToolEditionAndVersion installed = request.getInstalled(); - if (installed != null) { - restoreMissingVersionFile(getToolPath(), installed.getResolvedVersion()); - } - } - return super.toolAlreadyInstalled(request); - } - /** * Restores the {@link IdeContext#FILE_SOFTWARE_VERSION version file} of an existing installation in case it got lost (e.g. because {@code uv} recreated the * python virtual environment). Does nothing if the file is present or the version is unknown. @@ -414,12 +395,54 @@ protected EditionAndVersion computeInstalledEditionAndVersion() { return null; } // Resolve edition and version from a single tool-path lookup (one pass) instead of two separate lookups. - String edition = getInstalledEdition(toolPath); + Path repoPath = getInstalledSoftwareRepoPath(toolPath, true); + + if (repoPath != null) { + VersionIdentifier version = VersionIdentifier.of(repoPath.getFileName().toString()); + return new EditionAndVersion(getEditionFromSoftwareRepoPath(repoPath), version); + } + return computeInstalledEditionAndVersionFromLocalSoftwareFolder(); + } + + /** + * Determines the installed edition and version of a tool that is not installed via the software repository. + * + * @return the installed {@link EditionAndVersion} or {@code null} if the version cannot be determined. + */ + protected EditionAndVersion computeInstalledEditionAndVersionFromLocalSoftwareFolder() { + + Path toolPath = getToolPath(); VersionIdentifier version = getInstalledVersion(toolPath); if (version == null) { - return null; + version = computeInstalledVersionFromLocalSoftwareFolder(); + if (version == null) { + return null; + } + restoreMissingVersionFile(toolPath, version); } - return new EditionAndVersion(edition, version); + return new EditionAndVersion(computeInstalledEditionFromLocalSoftwareFolder(), version); + } + + /** + * Determines the installed edition of a tool that is not installed via the software repository. + * + * @return the installed edition of this tool. + */ + protected String computeInstalledEditionFromLocalSoftwareFolder() { + return this.tool; + } + + /** + * Determines the installed version of a tool that is not installed via the software repository, in case the + * {@link IdeContext#FILE_SOFTWARE_VERSION version file} is missing. + *

+ * By default {@code null} is returned since there is no tool-specific way to determine the version without the version file. Subclasses may override this to + * determine the version from the installation itself. + * + * @return the installed {@link VersionIdentifier version} or {@code null} if it cannot be determined. + */ + protected VersionIdentifier computeInstalledVersionFromLocalSoftwareFolder() { + return null; } /** @@ -455,47 +478,24 @@ protected VersionIdentifier getInstalledVersion(Path toolPath) { * @return the installed edition of this tool or {@code null} if not installed. */ protected String getInstalledEdition(Path toolPath) { + if (isToolNotInstalled(toolPath)) { return null; } - Path realPath = this.context.getFileAccess().toRealPath(toolPath); - // if the realPath changed, a link has been resolved - if (realPath.equals(toolPath)) { - if (!isIgnoreSoftwareRepo()) { - LOG.warn("Tool {} is not installed via software repository (maybe from devonfw-ide). Please consider reinstalling it.", this.tool); - } - // I do not see any reliable way how we could determine the edition of a tool that does not use software repo or that was installed by devonfw-ide - return getConfiguredEdition(); - } - Path toolRepoFolder = context.getSoftwareRepositoryPath().resolve(ToolRepository.ID_DEFAULT).resolve(this.tool); - String edition = getEdition(toolRepoFolder, realPath); - if (edition == null) { - edition = this.tool; - } - if (!getToolRepository().getSortedEditions(this.tool).contains(edition)) { - LOG.warn("Undefined edition {} of tool {}", edition, this.tool); + Path repoPath = getInstalledSoftwareRepoPath(toolPath, true); + if (repoPath == null) { + return computeInstalledEditionFromLocalSoftwareFolder(); } - return edition; + return getEditionFromSoftwareRepoPath(repoPath); } - private String getEdition(Path toolRepoFolder, Path toolInstallFolder) { + private String getEditionFromSoftwareRepoPath(Path repoPath) { - int toolRepoNameCount = toolRepoFolder.getNameCount(); - int toolInstallNameCount = toolInstallFolder.getNameCount(); - if (toolRepoNameCount < toolInstallNameCount) { - // ensure toolInstallFolder starts with $IDE_ROOT/_ide/software/default/«tool» - for (int i = 0; i < toolRepoNameCount; i++) { - if (!toolRepoFolder.getName(i).toString().equals(toolInstallFolder.getName(i).toString())) { - return null; - } - } - return toolInstallFolder.getName(toolRepoNameCount).toString(); + String edition = repoPath.getParent().getFileName().toString(); + if (!getToolRepository().getSortedEditions(this.tool).contains(edition)) { + LOG.warn("Undefined edition {} of tool {}", edition, this.tool); } - return null; - } - - private Path getInstalledSoftwareRepoPath(Path toolPath) { - return getInstalledSoftwareRepoPath(toolPath, false); + return edition; } private Path getInstalledSoftwareRepoPath(Path toolPath, boolean logIfNotSoftwareRepo) { @@ -516,35 +516,12 @@ private Path getInstalledSoftwareRepoPath(Path toolPath, boolean logIfNotSoftwar } Path getValidInstalledSoftwareRepoPath(Path installPath, Path softwareRepoPath) { - int softwareRepoNameCount = softwareRepoPath.getNameCount(); - int toolInstallNameCount = installPath.getNameCount(); - int targetToolInstallNameCount = softwareRepoNameCount + 4; - // installPath can't be shorter than softwareRepoPath - if (toolInstallNameCount < softwareRepoNameCount) { + Path result = this.context.getFileAccess().findAncestor(installPath, softwareRepoPath, 4); + if (result == null) { LOG.warn("The installation path is not located within the software repository {}.", installPath); - return null; - } - // ensure installPath starts with $IDE_ROOT/_ide/software/ - for (int i = 0; i < softwareRepoNameCount; i++) { - if (!softwareRepoPath.getName(i).toString().equals(installPath.getName(i).toString())) { - LOG.warn("The installation path is not located within the software repository {}.", installPath); - return null; - } - } - // return $IDE_ROOT/_ide/software/«id»/«tool»/«edition»/«version» - if (toolInstallNameCount == targetToolInstallNameCount) { - return installPath; - } else if (toolInstallNameCount > targetToolInstallNameCount) { - Path validInstallPath = installPath; - for (int i = 0; i < toolInstallNameCount - targetToolInstallNameCount; i++) { - validInstallPath = validInstallPath.getParent(); - } - return validInstallPath; - } else { - LOG.warn("The installation path is faulty {}.", installPath); - return null; } + return result; } private boolean isToolNotInstalled(Path toolPath) { @@ -634,14 +611,6 @@ protected Path getInstallationPath(String edition, VersionIdentifier resolvedVer return installationPath; } - /** - * @return {@link VersionIdentifier} with latest version of the tool}. - */ - public VersionIdentifier getLatestToolVersion() { - - return this.context.getDefaultToolRepository().resolveVersion(this.tool, getConfiguredEdition(), VersionIdentifier.LATEST, this); - } - /** * Searches for a wrapper file in valid projects (containing a build file f.e. build.gradle or pom.xml) and returns its path. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/python/Python.java b/cli/src/main/java/com/devonfw/tools/ide/tool/python/Python.java index 3bbcd1c2..e3a46fba 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/python/Python.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/python/Python.java @@ -3,6 +3,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.List; import java.util.Set; import org.slf4j.Logger; @@ -14,6 +15,10 @@ import com.devonfw.tools.ide.io.FileAccess; import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.process.EnvironmentContext; +import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.process.ProcessErrorHandling; +import com.devonfw.tools.ide.process.ProcessMode; +import com.devonfw.tools.ide.process.ProcessResult; import com.devonfw.tools.ide.tool.LocalToolCommandlet; import com.devonfw.tools.ide.tool.ToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallRequest; @@ -96,20 +101,15 @@ protected boolean isIgnoreMissingSoftwareVersionFile() { } @Override - protected VersionIdentifier getInstalledVersion(Path toolPath) { + protected VersionIdentifier computeInstalledVersionFromLocalSoftwareFolder() { - VersionIdentifier version = super.getInstalledVersion(toolPath); + Path toolPath = getToolPath(); + VersionIdentifier version = readVersionFromPyvenvCfg(toolPath); if (version == null) { - // the virtual environment can be recreated by uv or python and then the version file is lost, so we ask the - // installation itself instead of reporting that python is not installed - see - // https://github.com/devonfw/IDEasy/issues/2190 - version = readVersionFromPyvenvCfg(toolPath); - if (version == null) { - version = readVersionFromInterpreter(toolPath); - } - if (version != null) { - LOG.debug("Determined version {} of python from the installation at {}.", version, toolPath); - } + version = readVersionFromInterpreter(toolPath); + } + if (version != null) { + LOG.debug("Determined version {} of python from the installation at {}.", version, toolPath); } return version; } @@ -146,15 +146,14 @@ private VersionIdentifier readVersionFromPyvenvCfg(Path installationPath) { private VersionIdentifier readVersionFromInterpreter(Path installationPath) { Path binPath = this.context.getFileAccess().getBinPath(installationPath); - Path binaryPath = binPath.resolve(getBinaryName()); - if (!Files.exists(binaryPath)) { - binaryPath = binPath.resolve(getBinaryName() + ".exe"); - } + Path binaryPath = this.context.getPath().findBinary(binPath.resolve(getBinaryName())); if (!Files.exists(binaryPath)) { LOG.debug("Python binary does not exist in {}.", binPath); return null; } - String output = this.context.newProcess().runAndGetSingleOutput(IdeLogLevel.DEBUG, binaryPath.toString(), "--version"); + ProcessContext pc = this.context.newProcess().errorHandling(ProcessErrorHandling.NONE).withPathEntry(binPath); + ProcessResult result = runTool(pc, ProcessMode.DEFAULT_CAPTURE, List.of("--version")); + String output = result.getSingleOutput(IdeLogLevel.DEBUG); if (output == null) { return null; } @@ -166,7 +165,7 @@ private VersionIdentifier readVersionFromInterpreter(Path installationPath) { if (!version.isEmpty() && Character.isDigit(version.charAt(0))) { return VersionIdentifier.of(version); } - LOG.debug("Could not parse version from output '{}' of {}.", output, binaryPath); + LOG.debug("Could not parse version from output '{}' of {}.", output, binPath); return null; } diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/LocalToolCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/LocalToolCommandletTest.java index 04a82b7d..f5e0793a 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/LocalToolCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/LocalToolCommandletTest.java @@ -39,6 +39,69 @@ public static class LocalToolDummyCommandlet extends LocalToolCommandlet { } } + /** + * Dummy commandlet extending {@link LocalToolCommandlet} that tolerates a missing software version file (see + * {@link LocalToolCommandlet#isIgnoreMissingSoftwareVersionFile()}) and can determine its installed version from the installation itself (like python does). + * The repository lookups and the actual installation performed by {@link LocalToolCommandlet#installTool(ToolInstallRequest)} are short-circuited so the + * relevant branches can be tested in isolation without requiring the tool to be registered in the tool repository. + */ + public static class LocalToolRestoreVersionDummyCommandlet extends LocalToolDummyCommandlet { + + /** The version the dummy determines from the installation itself or {@code null} if it cannot determine it. */ + private final VersionIdentifier computedVersion; + + LocalToolRestoreVersionDummyCommandlet(IdeContext context) { + this(context, VersionIdentifier.of("9.9.9")); + } + + LocalToolRestoreVersionDummyCommandlet(IdeContext context, VersionIdentifier computedVersion) { + super(context); + this.computedVersion = computedVersion; + } + + @Override + protected boolean isIgnoreSoftwareRepo() { + + // like package-manager based tools (e.g. python) the installation lives directly in the software folder + return true; + } + + @Override + protected boolean isIgnoreMissingSoftwareVersionFile() { + + return true; + } + + @Override + protected VersionIdentifier cveCheck(ToolInstallRequest request) { + + return request.getRequested().getResolvedVersion(); + } + + @Override + protected void installToolDependencies(ToolInstallRequest request) { + + // the dummy tool has no dependencies + } + + /** + * @return the installed version of the dummy tool determined from the installation itself, or {@code null} if it cannot be determined. + */ + @Override + protected VersionIdentifier computeInstalledVersionFromLocalSoftwareFolder() { + + // as if the version was determined from the installation itself (e.g. by running the installed tool) + return this.computedVersion; + } + + @Override + protected void performToolInstallation(ToolInstallRequest request, Path installationPath) { + + // simulate a fresh installation without triggering a real download + context.writeVersionFile(request.getRequested().getResolvedVersion(), installationPath); + } + } + /** * Test {@link LocalToolCommandlet#getValidInstalledSoftwareRepoPath(Path, Path)} with a long installation path, as commonly encountered on macOS systems. */ @@ -47,7 +110,7 @@ void testGetValidInstalledSoftwareRepoPathWithLongPath() { // arrange Path installPath = Path.of("/projects/_ide/software/default/java/java/21.0.7_6/Contents/Resources/app/"); Path softwareRepoPath = Path.of("/projects/_ide/software/"); - Path expectedResultPath = Path.of("/projects/_ide/software/default/java/java/21.0.7_6/"); + Path expectedResultPath = Path.of("/projects/_ide/software/default/java/java/21.0.7_6/").toAbsolutePath().normalize(); IdeTestContext context = newContext(PROJECT_BASIC); LocalToolDummyCommandlet localToolCommandlet = new LocalToolDummyCommandlet(context); @@ -73,7 +136,7 @@ void testGetValidInstalledSoftwareRepoPathWithValidPath() { Path resultPath = localToolCommandlet.getValidInstalledSoftwareRepoPath(installPath, softwareRepoPath); // assert - assertThat(resultPath).isEqualTo(installPath); + assertThat(resultPath).isEqualTo(installPath.toAbsolutePath().normalize()); } /** @@ -92,7 +155,7 @@ void testGetValidInstalledSoftwareRepoPathWithShortPath() { // assert assertThat(resultPath).isNull(); - assertThat(context).log().hasEntries(IdeLogEntry.ofWarning("The installation path is faulty " + installPath + ".")); + assertThat(context).log().hasEntries(IdeLogEntry.ofWarning("The installation path is not located within the software repository " + installPath + ".")); } /** @@ -165,6 +228,104 @@ void testInstallAsDependencyIgnoresProject(WireMockRuntimeInfo wmRuntimeInfo) { assertThat(context).logAtDebug().hasMessageContaining("Ignoring project for dependency"); } + /** + * Verifies that {@link LocalToolCommandlet#installTool(ToolInstallRequest)} re-installs an existing installation when the software version file is missing + * and the installed version cannot be determined. + *

+ * The dummy tool cannot recover its installed version from the installation itself ({@code computeInstalledVersionFromLocalSoftwareFolder()} returns + * {@code null}). Since the version remains unknown, the installation isconsidered broken and must be re-installed instead of being preserved. + */ + @Test + void testInstallToolReinstallsWhenInstalledVersionNotDetermined() { + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + LocalToolRestoreVersionDummyCommandlet commandlet = new LocalToolRestoreVersionDummyCommandlet(context, null); + // an existing installation without a software version file for a tool that cannot determine its installed version + Path installationPath = context.getSoftwarePath().resolve("dummy"); + context.getFileAccess().mkdirs(installationPath); + context.getFileAccess().writeFileContent("tool content", installationPath.resolve("somefile.txt")); + Path versionFile = installationPath.resolve(IdeContext.FILE_SOFTWARE_VERSION); + assertThat(versionFile).doesNotExist(); + VersionIdentifier resolvedVersion = VersionIdentifier.of("1.2.3"); + ToolInstallRequest request = new ToolInstallRequest(false); + request.setRequested(new ToolEditionAndVersion(new ToolEdition("dummy", "dummy"), resolvedVersion)); + + // act + ToolInstallation installation = commandlet.installTool(request); + + // assert - the broken installation was re-installed (the dummy performToolInstallation writes the version file) + assertThat(installation.rootDir()).isEqualTo(installationPath); + assertThat(installation.newInstallation()).isTrue(); + assertThat(installation.resolvedVersion()).isEqualTo(resolvedVersion); + assertThat(versionFile).exists().hasContent("1.2.3"); + // assert - the tool was treated as broken (reinstalled), not as already installed and not as corrupted and deleted + assertThat(context).logAtWarning().hasMessageContaining("considered broken and will be reinstalled"); + assertThat(context).logAtWarning().hasNoMessageContaining("Deleting corrupted installation"); + } + + /** + * Verifies that a missing software version file is restored when the installed version can be determined from the installation itself. + *

+ * The tool reports its actual installed version via {@link LocalToolCommandlet#computeInstalledVersionFromLocalSoftwareFolder()}. That version is written + * back to the missing version file and the existing installation is kept if it matches the requested version. + */ + @Test + void testGetInstalledEditionAndVersionRestoresMissingVersionFile() { + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + LocalToolRestoreVersionDummyCommandlet commandlet = new LocalToolRestoreVersionDummyCommandlet(context); + Path installationPath = context.getSoftwarePath().resolve("dummy"); + context.getFileAccess().mkdirs(installationPath); + Path versionFile = installationPath.resolve(IdeContext.FILE_SOFTWARE_VERSION); + assertThat(versionFile).doesNotExist(); + + // act - determine the installed edition and version with the version file missing + EditionAndVersion installed = commandlet.getInstalledEditionAndVersion(); + + // assert - the version was determined from the installation itself + assertThat(installed).isNotNull(); + assertThat(installed.version()).isEqualTo(VersionIdentifier.of("9.9.9")); + // assert - the missing version file was restored with the determined (actual) version + assertThat(versionFile).exists().hasContent("9.9.9"); + assertThat(context).logAtWarning().hasMessageContaining("restoring it with version 9.9.9"); + } + + /** + * Verifies that a missing software version file does not trigger a reinstallation when the installed version can be determined and matches the requested + * version. + *

+ * The version file is restored with the detected installed version and the existing installation is preserved. + */ + @Test + void testInstallToolKeepsInstallationWhenInstalledVersionIsRestored() { + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + LocalToolRestoreVersionDummyCommandlet commandlet = new LocalToolRestoreVersionDummyCommandlet(context); + // an existing installation without a software version file; the tool determines its installed version from the installation itself (9.9.9) + Path installationPath = context.getSoftwarePath().resolve("dummy"); + context.getFileAccess().mkdirs(installationPath); + context.getFileAccess().writeFileContent("tool content", installationPath.resolve("somefile.txt")); + Path versionFile = installationPath.resolve(IdeContext.FILE_SOFTWARE_VERSION); + assertThat(versionFile).doesNotExist(); + ToolInstallRequest request = new ToolInstallRequest(false); + request.setRequested(new ToolEditionAndVersion(new ToolEdition("dummy", "dummy"), VersionIdentifier.of("9.9.9"))); + + // act + ToolInstallation installation = commandlet.installTool(request); + + // assert - the version file was restored with the installed version determined from the installation itself + assertThat(versionFile).exists().hasContent("9.9.9"); + // assert - the installation was kept (not reinstalled, because installed version matches the requested version) + assertThat(installation.rootDir()).isEqualTo(installationPath); + assertThat(installation.newInstallation()).isFalse(); + assertThat(installation.resolvedVersion()).isEqualTo(VersionIdentifier.of("9.9.9")); + assertThat(installationPath.resolve("somefile.txt")).exists(); + // assert - the tool was not treated as broken and reinstalled + assertThat(context).logAtWarning().hasMessageContaining("restoring it with version 9.9.9"); + assertThat(context).logAtWarning().hasNoMessageContaining("considered broken and will be reinstalled"); + assertThat(context).logAtWarning().hasNoMessageContaining("Deleting corrupted installation"); + } + private static void runIntellijAndCheckInstallationWithJavaDependency(IdeTestContext context) { // arrange context.getTestStartContext().getEntries().clear(); // clear logs from previous run(s) diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/python/PythonTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/python/PythonTest.java index a34683bf..bb917ac3 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/python/PythonTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/python/PythonTest.java @@ -94,7 +94,7 @@ public void testInstallRestoresMissingVersionFileAndPreservesPackages(WireMockRu // simulate that uv or python has removed our version file from the virtual environment Files.delete(versionFile); // the version is still determined from the installation itself (e.g. for "ide get-version python") - assertThat(python.getInstalledVersion(pythonPath)).isEqualTo(VersionIdentifier.of("3.14.6")); + assertThat(python.getInstalledVersion()).isEqualTo(VersionIdentifier.of("3.14.6")); // act python.install(); From e76da8e5baee947b9778e52fe154d5b150498c27 Mon Sep 17 00:00:00 2001 From: Laert Llaveshi Date: Mon, 21 Sep 2026 18:38:48 +0200 Subject: [PATCH 18/66] #2007: Support Docker on Arch Linux with pacman and yay (#2083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jörg Hohwiller --- CHANGELOG.adoc | 1 + .../tools/ide/tool/NativePackageManager.java | 29 +++++++++++++++++-- .../devonfw/tools/ide/tool/docker/Docker.java | 29 +++++++++++++++++-- .../ide/tool/NativePackageManagerTest.java | 20 +++++++++++++ 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index fff274cd..0435ab6f 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -12,6 +12,7 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/2488[#2488]: Add compiler and SDK to MSVC installation * https://github.com/devonfw/IDEasy/issues/2103[#2103]: Integrate Obsidian * https://github.com/devonfw/IDEasy/issues/1695[#1695]: Project creation logic extended with health checks and removed `--code` flag. +* https://github.com/devonfw/IDEasy/issues/2007[#2007]: Support Docker on Arch Linux with pacman and yay * https://github.com/devonfw/IDEasy/issues/2190[#2190]: IDEasy destroys my python installation * https://github.com/devonfw/IDEasy/issues/2178[#2178]: Make ReleaseCommandlet independent of specific build commandlet and fix `ide build` using npm instead of yarn * https://github.com/devonfw/IDEasy/issues/2142[#2142]: Move IDE-specific metadata (.idea, .vscode) out of workspace diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/NativePackageManager.java b/cli/src/main/java/com/devonfw/tools/ide/tool/NativePackageManager.java index cf505d95..614cef4c 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/NativePackageManager.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/NativePackageManager.java @@ -19,6 +19,12 @@ public enum NativePackageManager { /** DaNdiFied yum (DNF) is the package manager of RPM package based Linux distributions like Fedora. It is the successor of {@link #YUM}. */ DNF("dnf", "install -y", "remove -y", "-", "*", true), + /** Pacman is the package manager of Arch Linux based distributions. */ + PACMAN("pacman", "-S --needed --noconfirm", "-Rs --noconfirm", null, "", true), + + /** Yay is an AUR helper for packages that are not in the official Arch Linux repositories. */ + YAY("yay", "-S --needed --noconfirm", "-Rs --noconfirm", null, "", false), + /** Homebrew formula installation, the closest thing macOS has to a standard package manager. */ BREW("brew", "install", "uninstall", "@", "", false), @@ -66,6 +72,12 @@ public static NativePackageManager extractPackageManager(String command) { if (command.contains("dnf")) { return DNF; } + if (command.contains("yay")) { + return YAY; + } + if (command.contains("pacman")) { + return PACMAN; + } throw new IllegalArgumentException("Unknown package manager in command: " + command); } @@ -76,8 +88,8 @@ public String getBinaryName() { } /** - * @return {@code true} if commands of this {@link NativePackageManager} need to be run with {@code sudo} (root permissions), {@code false} otherwise (e.g. - * for {@link #BREW}/{@link #BREW_CASK} that must never be run as root). + * @return {@code true} if commands of this {@link NativePackageManager} need to be run with {@code sudo} (root permissions), + * {@code false} otherwise (e.g. for {@link #YAY} and {@link #BREW}/{@link #BREW_CASK} that must never be run as root). */ public boolean isNeedSudo() { @@ -93,7 +105,7 @@ public boolean isNeedSudo() { */ public String getPackageSpec(String pkg, String version) { - if ((version == null) || version.isBlank()) { + if ((version == null) || version.isBlank() || (this.versionSeparator == null)) { return pkg; } String spec = pkg + this.versionSeparator + version + this.versionWildCard; @@ -111,6 +123,7 @@ public List getVersionQueryCommand(String pkg) { List command = new ArrayList<>(switch (this) { case APT -> List.of("dpkg-query", "-W", "-f=${db:Status-Status}|${Version}"); case ZYPPER, YUM, DNF -> List.of("rpm", "-q", "--queryformat", "%{VERSION}"); + case PACMAN, YAY -> List.of("pacman", "-Q"); case BREW -> List.of(getBinaryName(), "list", "--versions"); case BREW_CASK -> List.of(getBinaryName(), "list", "--cask", "--versions"); }); @@ -133,6 +146,16 @@ public String parseVersionQueryOutput(String output) { return null; } version = parts[1].trim(); + } else if ((this == PACMAN) || (this == YAY)) { + String[] parts = version.split("\\s+"); + if (parts.length != 2) { + return null; + } + version = parts[1]; + int pkgRelIndex = version.lastIndexOf('-'); + if (pkgRelIndex > 0) { + version = version.substring(0, pkgRelIndex); + } } else if ((this == BREW) || (this == BREW_CASK)) { // output of "brew list --versions " is " " (possibly multiple space-separated versions, we take the last/newest one) int lastSpace = version.lastIndexOf(' '); diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/docker/Docker.java b/cli/src/main/java/com/devonfw/tools/ide/tool/docker/Docker.java index 33209924..6cfefde1 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/docker/Docker.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/docker/Docker.java @@ -16,6 +16,8 @@ import com.devonfw.tools.ide.tool.GlobalToolCommandlet; import com.devonfw.tools.ide.tool.NativePackage; import com.devonfw.tools.ide.tool.NativePackageManager; +import com.devonfw.tools.ide.tool.ToolEdition; +import com.devonfw.tools.ide.tool.ToolEditionAndVersion; import com.devonfw.tools.ide.version.VersionIdentifier; /** @@ -93,6 +95,7 @@ protected List getNativePackages() { "sudo rm -f /usr/share/keyrings/isv-rancher-stable-archive-keyring.gpg" ) ), + new NativePackage(NativePackageManager.YAY, List.of("rancher-desktop")), new NativePackage(NativePackageManager.BREW_CASK, List.of("docker")) ); } @@ -103,6 +106,19 @@ public String getMacApplicationName() { return "Docker"; } + @Override + protected ToolEditionAndVersion adjustRequestedEdition(ToolEditionAndVersion requested) { + + if (this.context.getSystemInfo().isLinux()) { + ToolEdition edition = requested.getEdition(); + if (!"rancher".equals(edition.edition())) { + LOG.warn("Docker Desktop is not yet supported by IDEasy on Linux, installing Rancher Desktop instead."); + requested.replaceEdition(new ToolEdition(this.tool, "rancher")); + } + } + return requested; + } + @Override public boolean isExtract() { @@ -122,6 +138,9 @@ protected EditionAndVersion computeInstalledEditionAndVersion() { if (isRancherDesktopInstalled()) { VersionIdentifier version = getRancherDesktopClientVersion(); + if (version == null) { + version = getNativePackageVersion(); + } return new EditionAndVersion("rancher", version); } @@ -171,8 +190,14 @@ private VersionIdentifier getDockerDesktopVersionMac() { private VersionIdentifier getRancherDesktopClientVersion() { - String output = this.context.newProcess().runAndGetSingleOutput("rdctl", "version"); - return resolveVersionWithPattern(output, RDCTL_CLIENT_VERSION_PATTERN); + // rdctl may be on the PATH as a dangling symlink (e.g. Rancher Desktop was removed but ~/.rd/bin remained) so executing it can fail to start the process + try { + String output = this.context.newProcess().runAndGetSingleOutput("rdctl", "version"); + return resolveVersionWithPattern(output, RDCTL_CLIENT_VERSION_PATTERN); + } catch (IllegalStateException e) { + LOG.warn("Could not determine the installed Rancher Desktop version - rdctl could not be executed: {}", e.getMessage()); + return null; + } } @Override diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/NativePackageManagerTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/NativePackageManagerTest.java index 605a2f93..69e7d0f5 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/NativePackageManagerTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/NativePackageManagerTest.java @@ -167,6 +167,26 @@ void testDnfUninstallCommand() { assertThat(cmd.commands()).containsExactly("sudo dnf remove -y pkg1", "sudo rm -f /etc/yum.repos.d/example.repo"); } + @Test + void testYayInstallCommand() { + NativePackage np = new NativePackage(NativePackageManager.YAY, List.of("rancher-desktop")); + + var cmd = NativePackageManager.YAY.install(np, "1.24.0"); + + assertThat(cmd.packageManager()).isEqualTo(NativePackageManager.YAY); + assertThat(cmd.commands()).containsExactly("yay -S --needed --noconfirm rancher-desktop"); + } + + @Test + void testYayUninstallCommand() { + NativePackage np = new NativePackage(NativePackageManager.YAY, List.of("rancher-desktop")); + + var cmd = NativePackageManager.YAY.uninstall(np); + + assertThat(cmd.packageManager()).isEqualTo(NativePackageManager.YAY); + assertThat(cmd.commands()).containsExactly("yay -Rs --noconfirm rancher-desktop"); + } + @Test void testVersionQueryCommandForDebianBasedPackageManager() { assertThat(NativePackageManager.APT.getVersionQueryCommand("pkg1")).containsExactly("dpkg-query", "-W", "-f=${db:Status-Status}|${Version}", From a0eb42d93b4ae35fcb5f46abb38e11a771f01cbc Mon Sep 17 00:00:00 2001 From: Ali Shariati Najafabadi <89353884+Ali-Shariati-Najafabadi@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:48:32 +0200 Subject: [PATCH 19/66] #2206: Fix IDEasy title not properly shown on macOS (#2315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Malte Brunnlieb Co-authored-by: Jörg Hohwiller --- CHANGELOG.adoc | 1 + .../com/devonfw/tools/ide/tool/gui/Gui.java | 51 ++++++++- .../devonfw/tools/ide/tool/gui/GuiTest.java | 100 +++++++++++++++++- 3 files changed, 144 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 0435ab6f..6f29afbb 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -6,6 +6,7 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDE Release with new features and bugfixes: +* https://github.com/devonfw/IDEasy/issues/2206[#2206]: IDEasy title not properly shown on macOS * https://github.com/devonfw/IDEasy/issues/377[#377]: Document Homebrew installation on MacOS * https://github.com/devonfw/IDEasy/issues/2338[#2338]: Create first basic version of check commandlet * https://github.com/devonfw/IDEasy/issues/2193[#2193]: Added support for conditional auto-completion diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/gui/Gui.java b/cli/src/main/java/com/devonfw/tools/ide/tool/gui/Gui.java index 0ccceabb..409fda70 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/gui/Gui.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/gui/Gui.java @@ -84,7 +84,7 @@ protected void doRun() { throw new CliException("Fatal error: The pom.xml file required for launching the IDEasy GUI could not be found in expected location: " + pomPath); } - List args = buildMvnArgs(installationPath, pomPath); + List args = buildMvnArgs(installationPath, pomPath, getGuiExecutable(javaInstallation)); /* * We manually update the PATH entry with our java version, as by default IDEasy includes the SymLink under /projectname/software/java/bin in the PATH @@ -110,15 +110,16 @@ protected void doRun() { * * @param installationPath the {@link IdeContext#getIdeInstallationPath() IDEasy installation} directory containing {@code gui/pom.xml}. * @param pomPath the launcher POM ({@code gui/pom.xml}) to launch the GUI from. Must be verified to exist before this is called. + * @param execExecutable the executable to pass as {@code -Dexec.executable} (see {@link #getGuiExecutable(ToolInstallation)}). * @return the {@code mvn} arguments to launch the GUI. */ - static List buildMvnArgs(Path installationPath, Path pomPath) { + static List buildMvnArgs(Path installationPath, Path pomPath, String execExecutable) { List args = new ArrayList<>(List.of( "-f", //use specified POM file pomPath.toString(), "org.codehaus.mojo:exec-maven-plugin:3.1.0:exec", - "-Dexec.executable=java", + "-Dexec.executable=" + execExecutable, "-Dexec.classpathScope=compile", "-Dexec.args=-classpath %classpath com.devonfw.ide.gui.AppLauncher", "-Dexec.async=true" @@ -134,4 +135,48 @@ static List buildMvnArgs(Path installationPath, Path pomPath) { } return args; } + + private static final String GUI_INFO_PLIST = """ + + + + + CFBundleExecutable + IDEasy + CFBundleIdentifier + com.devonfw.tools.ideasy.gui + CFBundleName + IDEasy + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + + + """; + + /** + * The GUI is launched as a plain {@code java} process without a native app bundle. On macOS this makes the Dock and menu bar show the executable's + * filename "java" instead of "IDEasy" (see issue #2206). Merely renaming/symlinking the bare executable is not reliable: without a real bundle, + * LaunchServices may still fall back to the underlying JDK's own identity. Instead, we wrap the launch in a minimal {@code .app} bundle: a proper + * {@code Info.plist} declaring our own {@code CFBundleName}/{@code CFBundleIdentifier}, with {@code Contents/MacOS/IDEasy} as a symlink pointing + * directly at the real java executable (not a wrapper script - an intermediate {@code exec} would replace the process image and lose the bundle + * identity again). + * + * @param javaInstallation the {@link ToolInstallation} of the java tool used to launch the GUI. + * @return the executable to pass to Maven's {@code exec:exec} goal in order to launch the GUI. + */ + String getGuiExecutable(ToolInstallation javaInstallation) { + + if (!this.context.getSystemInfo().isMac()) { + return "java"; + } + Path javaExecutable = javaInstallation.binDir().resolve("java"); + Path contentsDir = this.context.getTempPath().resolve("IDEasy.app").resolve("Contents"); + Path launcher = contentsDir.resolve("MacOS").resolve("IDEasy"); + this.context.getFileAccess().mkdirs(launcher.getParent()); + this.context.getFileAccess().writeFileContent(GUI_INFO_PLIST, contentsDir.resolve("Info.plist")); + this.context.getFileAccess().symlink(javaExecutable, launcher, false); + return launcher.toString(); + } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/gui/GuiTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/gui/GuiTest.java index 95335cbe..16b12267 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/gui/GuiTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/gui/GuiTest.java @@ -5,17 +5,22 @@ import java.nio.file.Path; import java.util.List; -import org.assertj.core.api.Assertions; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.io.WindowsSymlinkTestHelper; +import com.devonfw.tools.ide.os.SystemInfoMock; +import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.version.IdeVersion; +import com.devonfw.tools.ide.version.VersionIdentifier; /** * Test of {@link Gui}. */ -class GuiTest extends Assertions { +class GuiTest extends AbstractIdeContextTest { /** Version in effect before the tests so it can be restored by {@link #restoreVersion()}. */ private final String originalVersion = IdeVersion.getVersionString(); @@ -30,6 +35,76 @@ void restoreVersion() { IdeVersion.setMockVersionForTesting(this.originalVersion); } + private ToolInstallation newJavaInstallation(IdeTestContext context) { + + Path binDir = context.getSoftwarePath().resolve("java/bin"); + context.getFileAccess().mkdirs(binDir); + Path javaExecutable = binDir.resolve("java"); + try { + Files.createFile(javaExecutable); + } catch (Exception e) { + throw new IllegalStateException(e); + } + return new ToolInstallation(binDir.getParent(), binDir.getParent(), binDir, VersionIdentifier.of("25.0.0"), false); + } + + @Test + void testGetGuiExecutableOnMacCreatesAppBundleWithSymlinkNamedIDEasy() throws Exception { + + // arrange + WindowsSymlinkTestHelper.assumeSymlinksSupported(); + IdeTestContext context = newContext(PROJECT_BASIC, null, true); + context.setSystemInfo(SystemInfoMock.MAC_ARM64); + ToolInstallation javaInstallation = newJavaInstallation(context); + Gui gui = new Gui(context); + + // act + String executable = gui.getGuiExecutable(javaInstallation); + + // assert + Path contentsDir = context.getTempPath().resolve("IDEasy.app").resolve("Contents"); + Path launcher = contentsDir.resolve("MacOS").resolve("IDEasy"); + assertThat(executable).isEqualTo(launcher.toString()); + assertThat(Files.isSymbolicLink(launcher)).isTrue(); + assertThat(launcher.toRealPath()).isEqualTo(javaInstallation.binDir().resolve("java").toRealPath()); + Path infoPlist = contentsDir.resolve("Info.plist"); + assertThat(infoPlist).exists(); + assertThat(Files.readString(infoPlist)).contains("CFBundleName", "IDEasy", + "CFBundleIdentifier"); + } + + @Test + void testGetGuiExecutableOnWindowsReturnsPlainJava() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, null, true); + context.setSystemInfo(SystemInfoMock.WINDOWS_X64); + ToolInstallation javaInstallation = newJavaInstallation(context); + Gui gui = new Gui(context); + + // act + String executable = gui.getGuiExecutable(javaInstallation); + + // assert + assertThat(executable).isEqualTo("java"); + } + + @Test + void testGetGuiExecutableOnLinuxReturnsPlainJava() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, null, true); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + ToolInstallation javaInstallation = newJavaInstallation(context); + Gui gui = new Gui(context); + + // act + String executable = gui.getGuiExecutable(javaInstallation); + + // assert + assertThat(executable).isEqualTo("java"); + } + /** * Verifies the GUI is launched from the self-contained maven repository inside the installation when the running version is a local-dev build (stamped with * {@link IdeVersion#LOCAL_DEV_SUFFIX}), and that the snapshot (-U) behavior is not applied in that case. @@ -42,7 +117,7 @@ void testBuildMvnArgsWithLocalDevBuild() { Path installationPath = this.tempDir; // act - List args = Gui.buildMvnArgs(installationPath, installationPath.resolve("gui/pom.xml")); + List args = Gui.buildMvnArgs(installationPath, installationPath.resolve("gui/pom.xml"), "java"); // assert String m2RepoFlag = "-Dmaven.repo.local=" + installationPath.resolve(".m2"); @@ -62,7 +137,7 @@ void testBuildMvnArgsWithSnapshotVersion() throws IOException { String m2RepoFlag = "-Dmaven.repo.local=" + installationPath.resolve(".m2"); // act - List args = Gui.buildMvnArgs(installationPath, installationPath.resolve("gui/pom.xml")); + List args = Gui.buildMvnArgs(installationPath, installationPath.resolve("gui/pom.xml"), "java"); // assert assertThat(args).contains("-U"); @@ -82,11 +157,26 @@ void testBuildMvnArgsWithStableVersion() { String m2RepoFlag = "-Dmaven.repo.local=" + installationPath.resolve(".m2"); // act - List args = Gui.buildMvnArgs(installationPath, installationPath.resolve("gui/pom.xml")); + List args = Gui.buildMvnArgs(installationPath, installationPath.resolve("gui/pom.xml"), "java"); // assert assertThat(args).doesNotContain("-U"); assertThat(args).doesNotContain(m2RepoFlag); assertThat(args).doesNotContain("-o"); } + + @Test + void testBuildMvnArgsUsesGivenExecutable() { + + // arrange + Path installationPath = this.tempDir; + IdeVersion.setMockVersionForTesting("2026.08.001"); + + // act + List args = Gui.buildMvnArgs(installationPath, installationPath.resolve("gui/pom.xml"), "/tmp/IDEasy.app/Contents/MacOS/IDEasy"); + + // assert + assertThat(args).contains("-Dexec.executable=/tmp/IDEasy.app/Contents/MacOS/IDEasy"); + } + } From d2e67d9f84efc7c5ecb8a9ed367036e481a12486 Mon Sep 17 00:00:00 2001 From: cap-juan Date: Mon, 21 Sep 2026 18:53:24 +0200 Subject: [PATCH 20/66] #2271: fix pgadmin installation on linux (#2388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jörg Hohwiller --- CHANGELOG.adoc | 1 + .../tools/ide/tool/GlobalToolCommandlet.java | 27 +++++++++++-- .../tools/ide/tool/ToolCommandlet.java | 22 +++++++++-- .../ide/tool/GlobalToolCommandletTest.java | 39 +++++++++++++++++++ 4 files changed, 82 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 6f29afbb..985d114a 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -22,6 +22,7 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/1808[#1808]: Clarify the selected workspace in `ide` and `icd` messages * https://github.com/devonfw/IDEasy/issues/2252[#2252]: Add uninstall support for globally installed tools on macOS * https://github.com/devonfw/IDEasy/issues/1628[#1628]: Support for OS-specific CVEs +* https://github.com/devonfw/IDEasy/issues/2271[#2271]: Fix `ide install pgadmin` on Linux * https://github.com/devonfw/IDEasy/issues/2431[#2431]: Improve uv support to make it usable for IDE_ROOT on non-default drive The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/50?closed=1[milestone 2026.09.002]. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java index 3061e6d3..290a054a 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java @@ -23,6 +23,7 @@ import com.devonfw.tools.ide.process.ProcessResult; import com.devonfw.tools.ide.step.Step; import com.devonfw.tools.ide.tool.repository.ToolRepository; +import com.devonfw.tools.ide.version.GenericVersionRange; import com.devonfw.tools.ide.version.VersionIdentifier; /** @@ -140,12 +141,16 @@ protected ToolInstallation doInstall(ToolInstallRequest request) { VersionIdentifier resolvedVersion = request.getRequested().getResolvedVersion(); if (this.context.getSystemInfo().isLinux()) { // on Linux global tools are typically installed via the package manager of the OS - // if a global tool implements getNativePackages() to returns at least one NativePackage, then we will install this way. + // if a global tool implements getNativePackages() to return at least one NativePackage, then we will install this way. List commands = getInstallPackageManagerCommands(resolvedVersion); if (!commands.isEmpty()) { boolean newInstallation = runWithPackageManager(request.isSilent(), commands, NativePackageAction.INSTALL); - Path rootDir = getInstallationPath(getConfiguredEdition(), resolvedVersion); - return createToolInstallation(rootDir, resolvedVersion, newInstallation, request.getProcessContext(), request.isAdditionalInstallation()); + + VersionIdentifier installedVersion = getInstalledVersion(); + Path rootDir = getInstallationPath(getConfiguredEdition(), installedVersion); + + return createToolInstallation(rootDir, installedVersion, newInstallation, request.getProcessContext(), + request.isAdditionalInstallation()); } } @@ -191,6 +196,20 @@ protected ToolInstallation doInstall(ToolInstallRequest request) { return createToolInstallation(installationPath, resolvedVersion, true, pc, false); } + @Override + protected VersionIdentifier resolveVersionForInstall(String edition, GenericVersionRange version) { + + if (this.context.getSystemInfo().isLinux() && !getNativePackages().isEmpty()) { + if (version instanceof VersionIdentifier versionIdentifier && !versionIdentifier.isPattern()) { + return versionIdentifier; + } + + return null; + } + + return super.resolveVersionForInstall(edition, version); + } + /** * @return the {@link List} of {@link NativePackage}s this tool consists of on Linux - one per supported {@link NativePackageManager}. Override this method * instead of {@link #getInstallPackageManagerCommands} so that the commands for installation and uninstallation can both be derived from this @@ -314,7 +333,7 @@ protected Path getInstallationPath(String edition, VersionIdentifier resolvedVer public void uninstall() { if (this.context.getSystemInfo().isWindows()) { WindowsHelper.get(this.context).uninstallApplication(getWindowsRegistryAppName()); - } else if (this.context.getSystemInfo().isLinux()) { + } else if (this.context.getSystemInfo().isLinux() && !getNativePackages().isEmpty()) { runWithPackageManager(false, getUninstallPackageManagerCommands(), NativePackageAction.UNINSTALL); } else if (this.context.getSystemInfo().isMac()) { uninstallMac(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/ToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/ToolCommandlet.java index 8df3ac81..651d7625 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/ToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/ToolCommandlet.java @@ -462,6 +462,7 @@ private void completeRequestRequested(ToolInstallRequest request) { version = request.isIgnoreProject() ? VersionIdentifier.LATEST : getConfiguredVersion(); requested.setVersion(version); } + VersionIdentifier resolvedVersion = requested.getResolvedVersion(); if (resolvedVersion == null) { if (this.context.isSkipUpdatesMode()) { @@ -474,17 +475,32 @@ private void completeRequestRequested(ToolInstallRequest request) { } } if (resolvedVersion == null) { - resolvedVersion = getToolRepository().resolveVersion(this.tool, edition.edition(), version, this); + resolvedVersion = resolveVersionForInstall(edition.edition(), version); + } + + if (resolvedVersion != null) { + requested.setResolvedVersion(resolvedVersion); } - requested.setResolvedVersion(resolvedVersion); } } + /** + * Resolves the requested version for installation. + * + * @param edition the requested tool edition. + * @param version the requested version or version range. + * @return the resolved {@link VersionIdentifier} or {@code null} if version resolution is delegated to another installation mechanism. + */ + protected VersionIdentifier resolveVersionForInstall(String edition, GenericVersionRange version) { + + return getToolRepository().resolveVersion(this.tool, edition, version, this); + } + /** * Hook for subclasses to adjust the requested tool edition before the version is finalized. * * @param requested the requested {@link ToolEditionAndVersion} - * @return the given or trgansformed {@link ToolEditionAndVersion} + * @return the given or transformed {@link ToolEditionAndVersion} */ protected ToolEditionAndVersion adjustRequestedEdition(ToolEditionAndVersion requested) { diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java index c3ada193..aace8b76 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java @@ -208,6 +208,45 @@ void testGetUninstallPackageManagerCommandsDerivesFromNativePackages() { "sudo rm -f /etc/apt/sources.list.d/mytool.list"); } + /** + * Verifies that an explicitly requested exact version is preserved for a Linux tool installed via a native package manager. + */ + @Test + void testResolveVersionForInstallKeepsExactVersionForLinuxNativePackage() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + PackageManagedToolCommandlet commandlet = new PackageManagedToolCommandlet(context); + VersionIdentifier version = VersionIdentifier.of("1.2.3"); + + // act + VersionIdentifier resolvedVersion = commandlet.resolveVersionForInstall("default", version); + + // assert + assertThat(resolvedVersion).isEqualTo(version); + } + + /** + * Verifies that a version pattern is not resolved by IDEasy for a Linux tool installed via a native package manager, so the package manager can determine the + * concrete version. + */ + @Test + void testResolveVersionForInstallReturnsNullForPatternOnLinuxNativePackage() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + PackageManagedToolCommandlet commandlet = new PackageManagedToolCommandlet(context); + VersionIdentifier version = VersionIdentifier.of("1.2.*"); + + // act + VersionIdentifier resolvedVersion = commandlet.resolveVersionForInstall("default", version); + + // assert + assertThat(resolvedVersion).isNull(); + } + /** * Dummy {@link GlobalToolCommandlet} that declares a Homebrew cask for testing macOS uninstall via {@link NativePackageManager#BREW_CASK}. */ From b9eb9b35685ceba1bda1ec63729776d0f21cc1e5 Mon Sep 17 00:00:00 2001 From: T9 <103292348+shodiBoy1@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:12:37 +0200 Subject: [PATCH 21/66] CI status watch (#2091) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Malte Brunnlieb Co-authored-by: Jörg Hohwiller Co-authored-by: quando632 --- .github/workflows/ci-status-watch.yml | 123 +++++++++++ .github/workflows/issue-pr-observer.yml | 208 ++++++++++++++---- .../contributing/update-access-token.adoc | 4 +- 3 files changed, 287 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/ci-status-watch.yml diff --git a/.github/workflows/ci-status-watch.yml b/.github/workflows/ci-status-watch.yml new file mode 100644 index 00000000..620259d2 --- /dev/null +++ b/.github/workflows/ci-status-watch.yml @@ -0,0 +1,123 @@ +name: CI status watch + +# Posts a Teams message listing the README badge workflows that are currently red on the +# default branch. A workflow stays in the list until it is green again, so the reminder +# repeats every weekday morning until somebody fixes it. Creates no issues, read-only. +# If a status cannot be checked, Teams receives a warning to check it manually. +# Requires the repository secret TEAMS_WEBHOOK_URL. + +on: + workflow_dispatch: + schedule: + - cron: '0 7 * * 1-5' # 07:00 UTC, Mon-Fri (= 09:00 CEST / 08:00 CET) + +permissions: + actions: read + +jobs: + watch: + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WEBHOOK: ${{ secrets.TEAMS_WEBHOOK_URL }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.ref_name }} + steps: + - name: Check badge workflow status and notify Teams + run: | + set -euo pipefail + + # The workflows backing the README badges. Add a line here if a badge is added. + WORKFLOWS=" + .github/workflows/build.yml + .github/workflows/update-urls.yml + .github/workflows/nightly-build.yml + .github/workflows/integration-tests.yml + " + + echo "Checking badge workflows on branch $BRANCH" + + # Cancelled/skipped runs say nothing about the code and are ignored. A failed lookup + # is kept as an unknown status so Teams can ask the team to check it manually. + RESULTS=$(for WF_PATH in $WORKFLOWS; do + if RUNS=$(gh run list --repo "$REPO" --workflow "$WF_PATH" --branch "$BRANCH" \ + --status completed --limit 5 --json conclusion,url,updatedAt,workflowName); then + echo "$RUNS" | jq --arg workflow "$WF_PATH" ' + ([ .[] | select(.conclusion != "cancelled" and .conclusion != "skipped") ][0]) as $run + | if $run == null then + {state: "unknown", workflow: $workflow} + elif ($run.conclusion == "failure" or $run.conclusion == "timed_out" + or $run.conclusion == "startup_failure") then + {state: "red", name: $run.workflowName, url: $run.url, since: $run.updatedAt} + else + {state: "green", workflow: $workflow} + end' + else + echo "Status lookup failed for $WF_PATH." >&2 + jq -n --arg workflow "$WF_PATH" '{state: "unknown", workflow: $workflow}' + fi + done | jq -s '.') + + RED=$(echo "$RESULTS" | jq '[.[] | select(.state == "red")]') + UNKNOWN=$(echo "$RESULTS" | jq '[.[] | select(.state == "unknown")]') + RED_COUNT=$(echo "$RED" | jq 'length') + UNKNOWN_COUNT=$(echo "$UNKNOWN" | jq 'length') + if [ "$RED_COUNT" -eq 0 ] && [ "$UNKNOWN_COUNT" -eq 0 ]; then + echo "No badge workflow is red. Skipping Teams notification." + exit 0 + fi + echo "$RED_COUNT badge workflow(s) currently red; $UNKNOWN_COUNT status lookup(s) unknown." + + if [ -z "${WEBHOOK:-}" ]; then + echo "No Teams webhook set; notification cannot be delivered." + exit 1 + fi + + CARD=$(jq -n \ + --argjson red "$RED" \ + --argjson unknown "$UNKNOWN" \ + --argjson redCount "$RED_COUNT" \ + --argjson unknownCount "$UNKNOWN_COUNT" \ + --arg repo "$REPO" ' + { + type: "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.4", + body: ( + [ + { type: "TextBlock", size: "Large", weight: "Bolder", wrap: true, + color: (if $redCount > 0 then "attention" else "warning" end), + text: (if $redCount > 0 then "🔴 CI ist rot" else "⚠️ CI-Status unklar" end) } + ] + + (if $redCount > 0 then + [ { type: "TextBlock", spacing: "None", isSubtle: true, wrap: true, + text: (if $redCount == 1 then "1 Workflow ist rot" else "\($redCount) Workflows sind rot" end) } ] + + [ $red[] | { + type: "Container", separator: true, spacing: "Medium", + items: [ + { type: "TextBlock", weight: "Bolder", wrap: true, text: "🔴 \(.name)" }, + { type: "TextBlock", spacing: "None", isSubtle: true, size: "Small", wrap: true, + text: "rot seit \(.since) · [Run-Log öffnen](\(.url))" } + ] + } ] + else [] end) + + (if $unknownCount > 0 then + [ + { type: "TextBlock", weight: "Bolder", wrap: true, color: "warning", + spacing: "Large", text: "⚠️ Statusprüfung unvollständig" }, + { type: "TextBlock", wrap: true, + text: "Bitte den Status der folgenden Workflows manuell in GitHub prüfen." } + ] + + [ $unknown[] | { + type: "TextBlock", wrap: true, + text: "⚠️ [\(.workflow | split("/") | last)](https://github.com/\($repo)/actions/workflows/\(.workflow | split("/") | last))" + } ] + else [] end) + ) + }') + + curl -sS --fail -X POST -H "Content-Type: application/json" -d "$CARD" "$WEBHOOK" + if [ "$UNKNOWN_COUNT" -gt 0 ]; then + echo "At least one workflow status could not be checked." + exit 1 + fi diff --git a/.github/workflows/issue-pr-observer.yml b/.github/workflows/issue-pr-observer.yml index ab540a85..6cafa036 100644 --- a/.github/workflows/issue-pr-observer.yml +++ b/.github/workflows/issue-pr-observer.yml @@ -1,46 +1,122 @@ -name: External Issue & PR Observer +name: External Issue, PR & Discussion Observer -# Posts a weekday-morning digest of new EXTERNAL issues/PRs (last 24h) to a Teams -# channel so incoming reports from outside the team are noticed quickly and don't slip. -# "External" = author_association is not MEMBER, OWNER or COLLABORATOR. -# Requires the repository secret TEAMS_WEBHOOK_URL (a Teams "Workflows" incoming webhook). +# Posts a weekday-morning digest of new EXTERNAL issues, PRs and discussions (last 24h) +# to a Teams channel so incoming activity from outside the team is noticed quickly and +# "External" = author is not a member of a GitHub team linked to the IDEasy board +# (devonfw project 5). +# Issues/PRs come from the REST search API, discussions from GraphQL (no REST equivalent). +# A failed API call fails the run. The next run resumes from the last successful run, +# so activity is retried instead of being silently lost. +# Requires repository secret TEAMS_WEBHOOK_URL (Teams "Workflows" incoming webhook) +# and org secret ACTION_PUSH_TOKEN (Projects + Members read; see +# documentation/contributing/update-access-token.adoc). on: - workflow_dispatch: + workflow_dispatch: schedule: - cron: '0 7 * * 1-5' # 07:00 UTC, Mon-Fri (= 09:00 CEST summer / 08:00 CET winter) permissions: + actions: read issues: read pull-requests: read + discussions: read contents: read jobs: observe: runs-on: ubuntu-latest steps: - - name: Scan for new external issues & PRs and notify Teams + - name: Scan for new external issues, PRs & discussions and notify Teams env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ACTION_PUSH_TOKEN: ${{ secrets.ACTION_PUSH_TOKEN }} WEBHOOK: ${{ secrets.TEAMS_WEBHOOK_URL }} REPO: ${{ github.repository }} + BRANCH: ${{ github.ref_name }} + PROJECT_NUMBER: "5" + WORKFLOW: ".github/workflows/issue-pr-observer.yml" run: | - # Monday catches up the weekend (Fri–Sun); other weekdays look back 24h. - if [ "$(date -u +%u)" -eq 1 ]; then + set -euo pipefail + + # Resume from the previous successful observer run. If a GitHub API call or the + # Teams notification fails, that run stays failed and its activity is retried. + LAST_SUCCESS=$(gh run list --repo "$REPO" --workflow "$WORKFLOW" --branch "$BRANCH" \ + --status success --limit 1 --json createdAt --jq '.[0].createdAt // empty') \ + || { echo "Previous successful observer run fetch failed."; exit 1; } + if [ -n "$LAST_SUCCESS" ]; then + SINCE=$LAST_SUCCESS + elif [ "$(date -u +%u)" -eq 1 ]; then + # The first Monday run catches up the weekend (Fri–Sun). SINCE=$(date -u -d '3 days ago' +%Y-%m-%dT%H:%M:%SZ) else + # Other first runs look back 24 hours. SINCE=$(date -u -d '1 day ago' +%Y-%m-%dT%H:%M:%SZ) fi echo "Scanning $REPO for items created since $SINCE" - # Search returns both issues and PRs; keep only external authors (not on the team) - # and emit a structured object per item so the card can lay them out nicely. - ITEMS_JSON=$(gh api -X GET search/issues \ - -f q="repo:$REPO created:>=$SINCE" \ - --jq '[.items[] - | select(.author_association != "MEMBER" - and .author_association != "OWNER" - and .author_association != "COLLABORATOR") + if [ -z "$ACTION_PUSH_TOKEN" ]; then + echo "ACTION_PUSH_TOKEN is empty." + exit 1 + fi + + OWNER=${REPO%/*} + + # Fetch the GitHub teams explicitly linked to the IDEasy project. Project access is + # the source of truth; issue/PR assignments do not determine team membership. + PROJECT_TEAMS=$(GH_TOKEN="$ACTION_PUSH_TOKEN" gh api graphql \ + -F owner="$OWNER" -F project="$PROJECT_NUMBER" \ + -f query='query($owner: String!, $project: Int!) { + organization(login: $owner) { + projectV2(number: $project) { + teams(first: 10) { + totalCount + nodes { slug } + } + } + } + }' \ + --jq '.data.organization.projectV2.teams') \ + || { echo "Linked project-team fetch failed."; exit 1; } + if ! echo "$PROJECT_TEAMS" | jq -e ' + type == "object" + and (.nodes | type == "array") + and (.totalCount | type == "number")' >/dev/null; then + echo "Linked project-team response is invalid." + exit 1 + fi + if [ "$(echo "$PROJECT_TEAMS" | jq '.nodes | length')" -ne \ + "$(echo "$PROJECT_TEAMS" | jq '.totalCount')" ]; then + echo "More than 10 GitHub teams are linked to project $PROJECT_NUMBER." + exit 1 + fi + if [ "$(echo "$PROJECT_TEAMS" | jq '.totalCount')" -eq 0 ]; then + echo "No GitHub team is linked to project $PROJECT_NUMBER." + exit 1 + fi + + # Expand all linked teams (including inherited child-team members), normalize their + # logins and remove duplicates. Any failed page or team fetch fails the workflow. + TEAM=$({ + for TEAM_SLUG in $(echo "$PROJECT_TEAMS" | jq -r '.nodes[].slug'); do + GH_TOKEN="$ACTION_PUSH_TOKEN" gh api --method GET --paginate \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + "/orgs/$OWNER/teams/$TEAM_SLUG/members?per_page=100" \ + --jq '.[].login' || exit 1 + done + } | jq -Rsc 'split("\n") | map(select(length > 0) | ascii_downcase) | unique') \ + || { echo "Linked project-team member fetch failed."; exit 1; } + if ! echo "$TEAM" | jq -e 'type == "array" and length > 0' >/dev/null; then + echo "Linked project-team member list is empty or invalid." + exit 1 + fi + echo "Excluding $(echo "$TEAM" | jq 'length') linked project-team member(s)." + + # Issues + PRs via REST search; drop team authors, keep the rest. + ISSUE_ITEMS=$(gh api -X GET search/issues \ + -f q="repo:$REPO created:>=$SINCE" -f per_page=100 --jq '.items' \ + | jq --argjson team "$TEAM" '[.[] + | select((.user.login | ascii_downcase) as $u | $team | index($u) | not) | { kind: (if .pull_request then "Pull Request" else "Issue" end), icon: (if .pull_request then "🔵" else "🟢" end), @@ -48,40 +124,78 @@ jobs: title: .title, url: .html_url, user: .user.login - }]') + }]') \ + || { echo "Issue/PR fetch failed."; exit 1; } + + # No REST search for discussions; take newest 50 via GraphQL, filter by window + team. + NAME=${REPO#*/} + DISC_ITEMS=$(gh api graphql \ + -f owner="$OWNER" -f name="$NAME" \ + -f query=' + query($owner:String!, $name:String!) { + repository(owner:$owner, name:$name) { + discussions(first:50, orderBy:{field:CREATED_AT, direction:DESC}) { + nodes { number title url createdAt author { login } } + } + } + }' \ + --jq '.data.repository.discussions.nodes' \ + | jq --arg since "$SINCE" --argjson team "$TEAM" '[ .[] + | select(.createdAt >= $since) + | select(((.author.login // "") | ascii_downcase) as $u | $team | index($u) | not) + | { + kind: "Discussion", + icon: "💬", + number: .number, + title: .title, + url: .url, + user: (.author.login // "unknown") + } ]') \ + || { echo "Discussion fetch failed."; exit 1; } - COUNT=$(echo "$ITEMS_JSON" | jq 'length') + IP_COUNT=$(echo "$ISSUE_ITEMS" | jq 'length') + D_COUNT=$(echo "$DISC_ITEMS" | jq 'length') + COUNT=$((IP_COUNT + D_COUNT)) if [ "$COUNT" -eq 0 ]; then - echo "No new external issues or PRs. Skipping Teams-notification." + echo "No new external issues, PRs or discussions. Skipping Teams-notification." exit 0 fi - echo "$COUNT new external issue(s)/PR(s) found." + echo "$COUNT new external item(s): $IP_COUNT issue(s)/PR(s), $D_COUNT discussion(s)." - # Teams' webhook wants a bare Adaptive Card: top-level type must be - # "AdaptiveCard", not wrapped in a message/attachments envelope. - CARD=$(echo "$ITEMS_JSON" | jq --argjson count "$COUNT" '{ - type: "AdaptiveCard", - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - version: "1.4", - body: ( - [ - { type: "TextBlock", size: "Large", weight: "Bolder", wrap: true, - text: "🆕 Neue externe Issues & Pull Requests" }, - { type: "TextBlock", spacing: "None", isSubtle: true, wrap: true, - text: ("Letzter Zeitraum · " + (if $count == 1 then "1 neuer Eintrag" else "\($count) neue Einträge" end)) } - ] - + [ .[] | { - type: "Container", - separator: true, - spacing: "Medium", - items: [ - { type: "TextBlock", weight: "Bolder", wrap: true, - text: "\(.icon) #\(.number) · \(.title)" }, - { type: "TextBlock", spacing: "None", isSubtle: true, size: "Small", wrap: true, - text: "\(.kind) · von @\(.user) · [Öffnen](\(.url))" } - ] - } ] - ) - }') + # Bare Adaptive Card with one group per source; a group is rendered only if it has + # items, so issues/PRs and discussions stay separated and empty sections are hidden. + CARD=$(jq -n \ + --argjson ip "$ISSUE_ITEMS" \ + --argjson disc "$DISC_ITEMS" \ + --argjson count "$COUNT" ' + def section(title; items): + if (items | length) > 0 then + [ { type: "TextBlock", size: "Medium", weight: "Bolder", wrap: true, + spacing: "Large", text: title } ] + + [ items[] | { + type: "Container", separator: true, spacing: "Medium", + items: [ + { type: "TextBlock", weight: "Bolder", wrap: true, + text: "\(.icon) #\(.number) · \(.title)" }, + { type: "TextBlock", spacing: "None", isSubtle: true, size: "Small", wrap: true, + text: "\(.kind) · von @\(.user) · [Öffnen](\(.url))" } + ] + } ] + else [] end; + { + type: "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.4", + body: ( + [ + { type: "TextBlock", size: "Large", weight: "Bolder", wrap: true, + text: "🆕 Neue externe Aktivität" }, + { type: "TextBlock", spacing: "None", isSubtle: true, wrap: true, + text: ("Letzter Zeitraum · " + (if $count == 1 then "1 neuer Eintrag" else "\($count) neue Einträge" end)) } + ] + + section("📋 Issues & Pull Requests"; $ip) + + section("💬 Discussions"; $disc) + ) + }') curl -sS --fail -X POST -H "Content-Type: application/json" -d "$CARD" "$WEBHOOK" diff --git a/documentation/contributing/update-access-token.adoc b/documentation/contributing/update-access-token.adoc index ee374a18..a95c472e 100644 --- a/documentation/contributing/update-access-token.adoc +++ b/documentation/contributing/update-access-token.adoc @@ -2,7 +2,7 @@ :toc: toc::[] -To allow GitHub actions writing to our project (e.g. pushing changes) we need a fine-grained access token. +To allow GitHub actions writing to our project (e.g. pushing changes) and reading organization team membership (external issue observer) we need a fine-grained access token. Such token needs the according permissions and due to a GitHub policy it must expire after some time. When the expiry date is reached, out actions will fail. Therefore, shortly before this expiry date the token has to be replaced by a new one (refreshment). @@ -29,6 +29,8 @@ image::../images/AccessToken2.png["New fine-grained personal access token"] . In `Repository permissions` select `Read and write` for `Contents` (and `Metadata`). + image::../images/AccessToken3.png["Repository permissions"] +. In `Organization permissions` select `Read` for `Members` and `Read` for `Projects`. +These are required by `.github/workflows/issue-pr-observer.yml` to resolve GitHub teams linked to the IDEasy board and their members. . Click on `Generate token` to proceed. . On the confirmation page click again on `Generate token` to proceed. + From 6604a4b56251e729bfc1ea8026b532611ed8e974 Mon Sep 17 00:00:00 2001 From: JoelAdbu Date: Tue, 22 Sep 2026 17:12:34 +0200 Subject: [PATCH 22/66] #840: git fetch doesnt work with multiple remotes (#2524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Paras14 Co-authored-by: Paras14 <53565432+Paras14@users.noreply.github.com> Co-authored-by: Jörg Hohwiller --- .../com/devonfw/tools/ide/git/GitContext.java | 15 ++-- .../devonfw/tools/ide/git/GitContextImpl.java | 45 +++++++----- .../tools/ide/git/GitContextImplMock.java | 3 +- .../devonfw/tools/ide/git/GitContextMock.java | 2 +- .../devonfw/tools/ide/git/GitContextTest.java | 69 ++++++++++++++++--- 5 files changed, 100 insertions(+), 34 deletions(-) diff --git a/cli/src/main/java/com/devonfw/tools/ide/git/GitContext.java b/cli/src/main/java/com/devonfw/tools/ide/git/GitContext.java index 3d167b85..a2727031 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/git/GitContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/git/GitContext.java @@ -240,10 +240,13 @@ default void reset(Path repository, String branch) { String determineCurrentBranch(Path repository); /** + * Determines the remote that the branch of the given repository tracks (as configured via {@code branch..remote}). This is the same remote that + * {@link #isRepositoryUpdateAvailable(Path)} reads via {@code git rev-parse @{u}}. + * * @param repository the {@link Path} to the folder where the git repository is located. - * @return the name of the default origin. + * @return the name of the tracked remote (e.g. "origin") or {@code null} if the current branch has no upstream configured or no branch is checked out. */ - String determineRemote(Path repository); + String determineTrackedRemote(Path repository); /** * Saves the current git commit ID of a repository to a file given as an argument. @@ -296,7 +299,8 @@ default void push(Path repository) { * Pushes the local commits of the given repository to the remote repository. * * @param repository the {@link Path} to the git repository. - * @param followTags {@code true} to also push annotated tags reachable from the pushed commits (git push --follow-tags), {@code false} to push commits only. + * @param followTags {@code true} to also push annotated tags reachable from the pushed commits (git push --follow-tags), {@code false} to push commits + * only. */ void push(Path repository, boolean followTags); @@ -310,9 +314,8 @@ default void push(Path repository) { void addRemote(Path repository, String name, String url); /** - * Adds a new git remote to the given repository using {@code git remote add}. - * If the remote already exists with a different URL, an {@link IllegalStateException} is thrown - * instead of silently overriding it. + * Adds a new git remote to the given repository using {@code git remote add}. If the remote already exists with a different URL, an + * {@link IllegalStateException} is thrown instead of silently overriding it. * * @param repository the {@link Path} to the git repository. * @param name the name of the remote to add (e.g. "upstream"). diff --git a/cli/src/main/java/com/devonfw/tools/ide/git/GitContextImpl.java b/cli/src/main/java/com/devonfw/tools/ide/git/GitContextImpl.java index 553fcab5..7d002559 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/git/GitContextImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/git/GitContextImpl.java @@ -170,18 +170,19 @@ public void pullOrClone(GitUrl gitUrl, Path repository) { Objects.requireNonNull(repository); Objects.requireNonNull(gitUrl); - if (Files.isDirectory(repository.resolve(GIT_FOLDER))) { - // checks for remotes - String remote = determineRemote(repository); - if (remote == null) { - String message = repository + " is a local git repository with no remote - if you did this for testing, you may continue...\n" - + "Do you want to ignore the problem and continue anyhow?"; - this.context.askToContinue(message); - } else { - pull(repository); - } - } else { + if (!Files.isDirectory(repository.resolve(GIT_FOLDER))) { clone(gitUrl, repository); + return; + } + // the remote to pull from is the one the current branch is tracking (see also "git rev-parse @{u}") + String effectiveRemote = determineTrackedRemote(repository); + if (effectiveRemote == null) { + String message = repository + + " is a local git repository whose current branch does not track a remote - if you did this for testing, you may continue...\n" + + "Do you want to ignore the problem and continue anyhow?"; + this.context.askToContinue(message); + } else { + pull(repository); } } @@ -259,10 +260,16 @@ public void fetch(Path repository, String remote, String branch) { branch = determineCurrentBranch(repository); } if (remote == null) { - remote = determineRemote(repository); + // the remote to fetch from is the one the current branch is tracking (see also "git rev-parse @{u}") + remote = determineTrackedRemote(repository); + } + if (remote == null) { + // we are on a local branch without a configured upstream, there is no remote to fetch from + LOG.info("Skipping git fetch on {} because no remote is configured for branch {}.", repository, branch); + return; } - ProcessResult result = runGitCommand(repository, ProcessMode.DEFAULT_CAPTURE, "fetch", Objects.requireNonNullElse(remote, "origin"), branch); + ProcessResult result = runGitCommand(repository, ProcessMode.DEFAULT_CAPTURE, "fetch", remote, branch); if (!result.isSuccessful()) { LOG.warn("Git fetch for '{}/{} failed.'.", remote, branch); @@ -276,9 +283,13 @@ public String determineCurrentBranch(Path repository) { } @Override - public String determineRemote(Path repository) { + public String determineTrackedRemote(Path repository) { - return runGitCommandAndGetSingleOutput("Failed to determine current origin of git repository.", repository, "remote"); + String branch = determineCurrentBranch(repository); + if ((branch == null) || branch.isBlank()) { + return null; + } + return getOptionalGitConfigValue(repository, "branch." + branch + ".remote"); } @Override @@ -539,8 +550,8 @@ public void addRemoteOrFail(Path repository, String name, String url) { * @param repository the {@link Path} to the git repository. * @param name the name of the remote. * @param url the URL of the remote. - * @param failOnOverride {@code true} to throw an {@link IllegalStateException} if the remote already exists - * with a different URL, {@code false} to silently update it. + * @param failOnOverride {@code true} to throw an {@link IllegalStateException} if the remote already exists with a different URL, {@code false} to + * silently update it. */ protected void addRemote(Path repository, String name, String url, boolean failOnOverride) { diff --git a/cli/src/test/java/com/devonfw/tools/ide/git/GitContextImplMock.java b/cli/src/test/java/com/devonfw/tools/ide/git/GitContextImplMock.java index c5f6aa18..a677a3fa 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/git/GitContextImplMock.java +++ b/cli/src/test/java/com/devonfw/tools/ide/git/GitContextImplMock.java @@ -65,8 +65,7 @@ public void clone(GitUrl gitUrl, Path repository) { } @Override - public String determineRemote(Path repository) { - + public String determineTrackedRemote(Path repository) { return DEFAULT_REMOTE; } diff --git a/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMock.java b/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMock.java index c2069af4..86e660e4 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMock.java +++ b/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMock.java @@ -263,7 +263,7 @@ public String determineCurrentBranch(Path repository) { } @Override - public String determineRemote(Path repository) { + public String determineTrackedRemote(Path repository) { return DEFAULT_REMOTE; } diff --git a/cli/src/test/java/com/devonfw/tools/ide/git/GitContextTest.java b/cli/src/test/java/com/devonfw/tools/ide/git/GitContextTest.java index 745e1802..a2fad525 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/git/GitContextTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/git/GitContextTest.java @@ -1,7 +1,5 @@ package com.devonfw.tools.ide.git; -import static org.junit.jupiter.api.Assertions.assertThrows; - import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -36,6 +34,9 @@ class GitContextTest extends AbstractIdeContextTest { */ private class GitContextMock extends GitContextImpl { + /** Simulates the remote the current branch tracks via {@code branch..remote}; {@code null} simulates a branch without upstream. */ + private String trackedRemote = DEFAULT_REMOTE; + /** * @param context the {@link IdeContext context}. */ @@ -47,15 +48,30 @@ public GitContextMock(IdeContext context) { public Path findGitRequired() { return Path.of("git"); } + + @Override + public String determineTrackedRemote(Path repository) { + return this.trackedRemote; + } + + /** + * @param trackedRemote the remote the current branch tracks, or {@code null} to simulate a branch without upstream. + */ + public void setTrackedRemote(String trackedRemote) { + this.trackedRemote = trackedRemote; + } } + private GitContextMock gitContextMock; + private IdeTestContext newGitContext(Path dir) { IdeTestContext context = newContext(dir); context.getNetworkStatus().simulateOnline(); this.processContext = new ProcessContextGitMock(context, dir); context.setProcessContext(processContext); - context.setGitContext(new GitContextMock(context)); + this.gitContextMock = new GitContextMock(context); + context.setGitContext(this.gitContextMock); return context; } @@ -74,7 +90,7 @@ void testRunGitCloneInOfflineModeThrowsException(@TempDir Path tempDir) { context.getStartContext().setOfflineMode(true); // act - CliException e1 = assertThrows(CliException.class, () -> { + CliException e1 = catchThrowableOfType(CliException.class, () -> { context.getGitContext().pullOrClone(gitUrl, tempDir); }); // assert @@ -112,8 +128,6 @@ void testRunGitPullWithoutForce(@TempDir Path tempDir) { // arrange String gitRepoUrl = "https://github.com/test"; IdeTestContext context = newGitContext(tempDir); - OutputMessage outputMessage = new OutputMessage(false, "test-remote"); - this.processContext.addOutputMessage(outputMessage); FileAccess fileAccess = new FileAccessImpl(context); Path gitFolderPath = tempDir.resolve(GitContext.GIT_FOLDER); fileAccess.mkdirs(gitFolderPath); @@ -123,6 +137,45 @@ void testRunGitPullWithoutForce(@TempDir Path tempDir) { assertThat(tempDir.resolve(GitContext.GIT_FOLDER).resolve("update")).hasContent(this.processContext.getNow().toString()); } + /** + * Runs a simulated git pull on a repository whose current branch tracks a non-default remote (as it would in a repository with multiple remotes) and checks + * that the pull is performed instead of asking the user to continue. See issue #840. + * + * @param tempDir a {@link TempDir} {@link Path}. + */ + @Test + void testRunGitPullWithTrackedRemote(@TempDir Path tempDir) { + + // arrange - a non-default tracked remote, as in a repository with multiple remotes + String gitRepoUrl = "https://github.com/test"; + IdeTestContext context = newGitContext(tempDir); + this.gitContextMock.setTrackedRemote("upstream"); + FileAccess fileAccess = new FileAccessImpl(context); + fileAccess.mkdirs(tempDir.resolve(GitContext.GIT_FOLDER)); + // act + context.getGitContext().pullOrClone(GitUrl.of(gitRepoUrl), tempDir); + // assert + assertThat(tempDir.resolve(GitContext.GIT_FOLDER).resolve("update")).hasContent(this.processContext.getNow().toString()); + } + + /** + * Runs a simulated git pull on a repository without any remote and checks that the user is asked whether to continue. + * + * @param tempDir a {@link TempDir} {@link Path}. + */ + @Test + void testRunGitPullWithoutRemoteAsksToContinue(@TempDir Path tempDir) { + + // arrange + String gitRepoUrl = "https://github.com/test"; + IdeTestContext context = newGitContext(tempDir); + this.gitContextMock.setTrackedRemote(null); + FileAccess fileAccess = new FileAccessImpl(context); + fileAccess.mkdirs(tempDir.resolve(GitContext.GIT_FOLDER)); + // act + assert (no answers are configured, so asking a question fails) + assertThatThrownBy(() -> context.getGitContext().pullOrClone(GitUrl.of(gitRepoUrl), tempDir)).isInstanceOf(IllegalStateException.class); + } + /** * Runs a git pull with force mode, creates temporary files to simulate a proper cleanup. * @@ -186,7 +239,7 @@ void testRunGitPullWithForceStartsCleanup(@TempDir Path tempDir) { @Test void testGitRepoIsRecognizedCorrectly(@TempDir Path tempDir) { String gitRepoUrl = "https://github.com/test"; - + IdeTestContext context = newGitContext(tempDir); GitContext gitContext = context.getGitContext(); @@ -199,7 +252,7 @@ void testGitRepoIsRecognizedCorrectly(@TempDir Path tempDir) { void testNormalDirIsNoRepo(@TempDir Path tempDir) { IdeTestContext context = newGitContext(tempDir); GitContext gitContext = context.getGitContext(); - + FileAccess fileAccess = context.getFileAccess(); fileAccess.mkdirs(tempDir.resolve("new-folder")); From 8d1ff48a4f26f8aa4006e1e518dfe856dcf11b25 Mon Sep 17 00:00:00 2001 From: T9 <103292348+shodiBoy1@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:50:27 +0200 Subject: [PATCH 23/66] #2540: Skip Teams notification jobs in forks (#2543) --- .github/workflows/ci-status-watch.yml | 1 + .github/workflows/issue-pr-observer.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci-status-watch.yml b/.github/workflows/ci-status-watch.yml index 620259d2..acc0b559 100644 --- a/.github/workflows/ci-status-watch.yml +++ b/.github/workflows/ci-status-watch.yml @@ -16,6 +16,7 @@ permissions: jobs: watch: + if: github.repository == 'devonfw/IDEasy' runs-on: ubuntu-latest env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/issue-pr-observer.yml b/.github/workflows/issue-pr-observer.yml index 6cafa036..34c1836f 100644 --- a/.github/workflows/issue-pr-observer.yml +++ b/.github/workflows/issue-pr-observer.yml @@ -25,6 +25,7 @@ permissions: jobs: observe: + if: github.repository == 'devonfw/IDEasy' runs-on: ubuntu-latest steps: - name: Scan for new external issues, PRs & discussions and notify Teams From fa2012a0c9f2860b90c898da037b59068558c206 Mon Sep 17 00:00:00 2001 From: quando632 Date: Thu, 24 Sep 2026 10:40:40 +0200 Subject: [PATCH 24/66] #2509: merge VSCode user settings template into --user-data-dir (#2526) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jörg Hohwiller --- .../tools/ide/merge/DirectoryMerger.java | 21 +++++- .../tools/ide/tool/ide/IdeToolCommandlet.java | 28 +++++--- .../devonfw/tools/ide/tool/vscode/Vscode.java | 52 +++++++++++++- .../tools/ide/tool/vscode/VscodeTest.java | 69 +++++++++++++++++++ .../.vscode/.userdata/User/settings.json | 4 ++ .../workspace/update/.vscode/settings.json | 3 + 6 files changed, 167 insertions(+), 10 deletions(-) create mode 100644 cli/src/test/resources/ide-projects/vscode/project/settings/vscode/workspace/update/.vscode/.userdata/User/settings.json create mode 100644 cli/src/test/resources/ide-projects/vscode/project/settings/vscode/workspace/update/.vscode/settings.json diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/DirectoryMerger.java b/cli/src/main/java/com/devonfw/tools/ide/merge/DirectoryMerger.java index 5108a85c..a9e3036d 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/merge/DirectoryMerger.java +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/DirectoryMerger.java @@ -63,6 +63,23 @@ public DirectoryMerger(IdeContext context) { @Override public int merge(Path setup, Path update, EnvironmentVariables variables, Path workspace) { + return merge(setup, update, variables, workspace, Map.of()); + } + + /** + * Same as {@link #merge(Path, Path, EnvironmentVariables, Path)} but allows to merge templates of specific sub-folders to a different location outside the + * {@code workspace}. + * + * @param setup the setup {@link Path} for creation. + * @param update the update {@link Path} for creation and update. + * @param variables the {@link EnvironmentVariables} to {@link EnvironmentVariables#resolve(String, Object) resolve variables}. + * @param workspace the workspace {@link Path} to create or update. + * @param redirects the {@link Map} with the {@link Path}s inside the {@code workspace} as keys and the {@link Path}s where to merge them instead as + * values. + * @return the number of errors that occurred. Should be {@code 0} for success. + */ + public int merge(Path setup, Path update, EnvironmentVariables variables, Path workspace, Map redirects) { + int errors = 0; Set children = null; children = addChildren(setup, children); @@ -74,7 +91,9 @@ public int merge(Path setup, Path update, EnvironmentVariables variables, Path w } else { // directory scan for (String filename : children) { - errors += merge(setup.resolve(filename), update.resolve(filename), variables, workspace.resolve(filename)); + Path target = workspace.resolve(filename); + target = redirects.getOrDefault(target, target); + errors += merge(setup.resolve(filename), update.resolve(filename), variables, target, redirects); } } return errors; diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeToolCommandlet.java index f30f44b8..f9337c95 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeToolCommandlet.java @@ -145,9 +145,10 @@ public void configureWorkspace() { private void doMergeWorkspaceStep(Step step, Path workspaceFolder) { int errors = 0; - errors = mergeWorkspace(this.context.getUserHomeIde(), workspaceFolder, errors); - errors = mergeWorkspace(this.context.getSettingsPath(), workspaceFolder, errors); - errors = mergeWorkspace(this.context.getConfPath(), workspaceFolder, errors); + Map redirects = getWorkspaceRedirects(workspaceFolder); + errors = mergeWorkspace(this.context.getUserHomeIde(), workspaceFolder, redirects, errors); + errors = mergeWorkspace(this.context.getSettingsPath(), workspaceFolder, redirects, errors); + errors = mergeWorkspace(this.context.getConfPath(), workspaceFolder, redirects, errors); synchronizeExtraToolInstallations(); @@ -162,15 +163,26 @@ private void doMergeWorkspaceStep(Step step, Path workspaceFolder) { } } - private int mergeWorkspace(Path configFolder, Path workspaceFolder, int errors) { + /** + * @param workspaceFolder the {@link IdeContext#getWorkspacePath() workspace folder}. + * @return the {@link Map} with the {@link Path}s inside the given {@code workspaceFolder} as keys and the {@link Path}s where the according workspace + * templates shall be merged to instead as values. Allows to keep IDE-specific data out of the workspace (e.g. in {@link #getIdeMetadataPath()}) without + * changing the structure of the workspace templates in the settings. By default, nothing is redirected. + */ + protected Map getWorkspaceRedirects(Path workspaceFolder) { + + return Map.of(); + } + + private int mergeWorkspace(Path configFolder, Path workspaceFolder, Map redirects, int errors) { int result = errors; - result = mergeWorkspaceSingle(configFolder.resolve(IdeContext.FOLDER_WORKSPACE), workspaceFolder, result); - result = mergeWorkspaceSingle(configFolder.resolve(this.tool).resolve(IdeContext.FOLDER_WORKSPACE), workspaceFolder, result); + result = mergeWorkspaceSingle(configFolder.resolve(IdeContext.FOLDER_WORKSPACE), workspaceFolder, redirects, result); + result = mergeWorkspaceSingle(configFolder.resolve(this.tool).resolve(IdeContext.FOLDER_WORKSPACE), workspaceFolder, redirects, result); return result; } - private int mergeWorkspaceSingle(Path templatesFolder, Path workspaceFolder, int errors) { + private int mergeWorkspaceSingle(Path templatesFolder, Path workspaceFolder, Map redirects, int errors) { Path setupFolder = templatesFolder.resolve(IdeContext.FOLDER_SETUP); Path updateFolder = templatesFolder.resolve(IdeContext.FOLDER_UPDATE); @@ -179,7 +191,7 @@ private int mergeWorkspaceSingle(Path templatesFolder, Path workspaceFolder, int return errors; } LOG.debug("Merging workspace templates from {}...", templatesFolder); - return errors + this.context.getWorkspaceMerger().merge(setupFolder, updateFolder, this.context.getVariables(), workspaceFolder); + return errors + this.context.getWorkspaceMerger().merge(setupFolder, updateFolder, this.context.getVariables(), workspaceFolder, redirects); } /** diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/vscode/Vscode.java b/cli/src/main/java/com/devonfw/tools/ide/tool/vscode/Vscode.java index 3ce71ddd..b88edf92 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/vscode/Vscode.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/vscode/Vscode.java @@ -1,8 +1,10 @@ package com.devonfw.tools.ide.tool.vscode; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Set; import org.slf4j.Logger; @@ -10,6 +12,7 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.io.FileAccess; import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.process.ProcessContext; import com.devonfw.tools.ide.process.ProcessMode; @@ -30,6 +33,9 @@ public class Vscode extends IdeToolCommandlet { /** The {@link #getConfiguredEdition() edition} for VSCodium. */ private static final String EDITION_VSCODIUM = "vscodium"; + /** The {@link Path} of the legacy VSCode user-data folder relative to the workspace, still used by the workspace templates in the settings. */ + private static final Path LEGACY_USER_DATA = Path.of(".vscode", ".userdata"); + /** * The constructor. * @@ -89,6 +95,50 @@ private String getProfileName() { return "ideasy-" + this.context.getProjectName() + "-" + this.context.getWorkspaceName(); } + /** + * @return the {@link Path} to the VSCode user-data folder passed via {@code --user-data-dir}. + */ + private Path getUserDataPath() { + + return getIdeMetadataPath().resolve("config"); + } + + @Override + public void configureWorkspace() { + + cleanupLegacyUserData(); + super.configureWorkspace(); + } + + /** + * Removes the legacy user-data folder from the workspace that VSCode does not read anymore (see #2142 and #2509). If the actual user-data folder does not + * yet exist, the legacy folder is moved there to preserve its content, otherwise it is backed up. + */ + private void cleanupLegacyUserData() { + + Path legacyUserData = this.context.getWorkspacePath().resolve(LEGACY_USER_DATA); + if (!Files.isDirectory(legacyUserData)) { + return; + } + FileAccess fileAccess = this.context.getFileAccess(); + Path userData = getUserDataPath(); + if (Files.exists(userData)) { + LOG.warn("Removing obsolete VSCode user-data folder {} from workspace since VSCode uses {}", legacyUserData, userData); + fileAccess.backup(legacyUserData); + } else { + LOG.info("Moving VSCode user-data folder {} out of workspace to {}", legacyUserData, userData); + fileAccess.mkdirs(userData.getParent()); + fileAccess.move(legacyUserData, userData); + } + } + + @Override + protected Map getWorkspaceRedirects(Path workspaceFolder) { + + // the settings still provide the user settings template in the legacy location inside the workspace + return Map.of(workspaceFolder.resolve(LEGACY_USER_DATA), getUserDataPath()); + } + @Override protected void configureToolArgs(ProcessContext pc, ProcessMode processMode, List args) { @@ -102,7 +152,7 @@ protected void configureToolArgs(ProcessContext pc, ProcessMode processMode, Lis // already-running IDEasy window. Each project and workspace gets its own profile for isolated auth and settings. pc.addArg("--profile=" + getProfileName()); } else { - pc.addArg("--user-data-dir=" + getIdeMetadataPath().resolve("config")); + pc.addArg("--user-data-dir=" + getUserDataPath()); } Path vsCodeExtensionFolder = this.context.getIdeHome().resolve("plugins/vscode"); pc.addArg("--extensions-dir=" + vsCodeExtensionFolder); diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/vscode/VscodeTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/vscode/VscodeTest.java index e537ca07..518ebc98 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/vscode/VscodeTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/vscode/VscodeTest.java @@ -15,6 +15,7 @@ import com.devonfw.tools.ide.context.IdeTestContext; import com.devonfw.tools.ide.context.ProcessContextTestImpl; import com.devonfw.tools.ide.environment.EnvironmentVariablesType; +import com.devonfw.tools.ide.io.FileAccess; import com.devonfw.tools.ide.os.SystemInfoMock; import com.devonfw.tools.ide.process.ProcessContext; import com.devonfw.tools.ide.process.ProcessMode; @@ -200,6 +201,74 @@ void testConfigureToolArgsUsesProfileIfFeatureToggleEnabled() { assertThat(pc.capturedArgs).contains("--profile=ideasy-" + context.getProjectName() + "-" + context.getWorkspaceName()); } + /** + * Tests that the user settings template {@code .vscode/.userdata} from the settings repository is merged into the VS Code user-data folder + * ({@code $IDE_HOME/.ide/vscode/«workspace»/config}) passed via {@code --user-data-dir} instead of the workspace, while the other templates are still merged + * into the workspace (see #2509). + */ + @Test + void testConfigureWorkspaceMergesUserDataTemplateIntoUserDataFolder() { + + // arrange + IdeTestContext context = newContext(PROJECT_VSCODE); + Vscode commandlet = new Vscode(context); + Path workspace = context.getWorkspacePath(); + // act + commandlet.configureWorkspace(); + // assert + assertThat(getUserDataPath(context).resolve("User/settings.json")).exists().content().contains("\"telemetry.telemetryLevel\": \"off\"") + .contains("\"update.mode\": \"none\""); + assertThat(workspace.resolve(".vscode/.userdata")).doesNotExist(); + assertThat(workspace.resolve(".vscode/settings.json")).exists().content().contains("\"editor.formatOnSave\": true"); + } + + /** + * Tests that a {@code .vscode/.userdata} folder left in the workspace is moved to the VS Code user-data folder if that does not exist yet. + */ + @Test + void testConfigureWorkspaceMovesLegacyUserDataIfUserDataFolderIsMissing() { + + // arrange + IdeTestContext context = newContext(PROJECT_VSCODE); + FileAccess fileAccess = context.getFileAccess(); + Path legacyUserData = context.getWorkspacePath().resolve(".vscode/.userdata"); + fileAccess.writeFileContent("legacy", legacyUserData.resolve("state.json"), true); + Vscode commandlet = new Vscode(context); + // act + commandlet.configureWorkspace(); + // assert + assertThat(getUserDataPath(context).resolve("state.json")).exists().hasContent("legacy"); + assertThat(legacyUserData).doesNotExist(); + } + + /** + * Tests that a {@code .vscode/.userdata} folder left in the workspace is removed from the workspace (via backup) without touching the VS Code user-data + * folder if that already exists. + */ + @Test + void testConfigureWorkspaceRemovesLegacyUserDataIfUserDataFolderExists() { + + // arrange + IdeTestContext context = newContext(PROJECT_VSCODE); + FileAccess fileAccess = context.getFileAccess(); + Path legacyUserData = context.getWorkspacePath().resolve(".vscode/.userdata"); + fileAccess.writeFileContent("legacy", legacyUserData.resolve("state.json"), true); + Path userData = getUserDataPath(context); + fileAccess.writeFileContent("current", userData.resolve("state.json"), true); + Vscode commandlet = new Vscode(context); + // act + commandlet.configureWorkspace(); + // assert + assertThat(userData.resolve("state.json")).exists().hasContent("current"); + assertThat(legacyUserData).doesNotExist(); + assertThat(context.getIdeHome().resolve(IdeContext.FOLDER_BACKUPS)).exists(); + } + + private static Path getUserDataPath(IdeTestContext context) { + + return context.getIdeHome().resolve(IdeContext.FOLDER_DOT_IDE).resolve("vscode").resolve(context.getWorkspaceName()).resolve("config"); + } + /** * Tests that {@code VSCODE_OPTIONS} is honoured by appending its tokens as additional command-line arguments when starting the IDE (analogue to the * global {@code IDE_OPTIONS} used for IDEasy itself, see issue #788). diff --git a/cli/src/test/resources/ide-projects/vscode/project/settings/vscode/workspace/update/.vscode/.userdata/User/settings.json b/cli/src/test/resources/ide-projects/vscode/project/settings/vscode/workspace/update/.vscode/.userdata/User/settings.json new file mode 100644 index 00000000..2d5a24e6 --- /dev/null +++ b/cli/src/test/resources/ide-projects/vscode/project/settings/vscode/workspace/update/.vscode/.userdata/User/settings.json @@ -0,0 +1,4 @@ +{ + "telemetry.telemetryLevel": "off", + "update.mode": "none" +} diff --git a/cli/src/test/resources/ide-projects/vscode/project/settings/vscode/workspace/update/.vscode/settings.json b/cli/src/test/resources/ide-projects/vscode/project/settings/vscode/workspace/update/.vscode/settings.json new file mode 100644 index 00000000..ad92582b --- /dev/null +++ b/cli/src/test/resources/ide-projects/vscode/project/settings/vscode/workspace/update/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.formatOnSave": true +} From de3f80deeed14b662e5bdf04c0330602e3d9f3fa Mon Sep 17 00:00:00 2001 From: Franck Majeste Silatsa <105231573+majesteSil@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:46:58 +0200 Subject: [PATCH 25/66] #607: test that IDEA_PROPERTIES and STUDIO_PROPERTIES are set on the process context (#2490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jörg Hohwiller --- .../tool/androidstudio/AndroidStudioTest.java | 23 +++++++++++++++++++ .../tools/ide/tool/intellij/IntellijTest.java | 21 +++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/androidstudio/AndroidStudioTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/androidstudio/AndroidStudioTest.java index 620f243e..ea5e46cd 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/androidstudio/AndroidStudioTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/androidstudio/AndroidStudioTest.java @@ -10,6 +10,7 @@ import java.nio.file.Path; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -20,6 +21,9 @@ 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.tool.ToolInstallation; +import com.devonfw.tools.ide.tool.claude.RecordingEnvironmentContext; +import com.devonfw.tools.ide.version.VersionIdentifier; import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; @@ -113,6 +117,25 @@ void testAndroidStudioRunWithCustomJvmOptions(String os) { """); } + /** + * Tests if the environment variable {@code STUDIO_PROPERTIES} is set to the path of the {@code studio.properties} file in the workspace. + */ + @Test + void testSetEnvironmentSetsStudioProperties() { + + // arrange + AndroidStudio commandlet = new AndroidStudio(this.context); + Path dummy = this.context.getSoftwarePath().resolve("android-studio"); + ToolInstallation installation = new ToolInstallation(dummy, dummy, dummy, VersionIdentifier.of("2024.1.1.1"), false); + RecordingEnvironmentContext environmentContext = new RecordingEnvironmentContext(); + + // act + commandlet.setEnvironment(environmentContext, installation, false); + + // assert + assertThat(environmentContext.set).containsEntry("STUDIO_PROPERTIES", this.context.getWorkspacePath().resolve("studio.properties").toString()); + } + private void checkInstallation(IdeTestContext context) { // commandlet - android-studio AndroidStudio commandlet = context.getCommandletManager().getCommandlet(AndroidStudio.class); diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/intellij/IntellijTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/intellij/IntellijTest.java index 2791568b..5d64a3e2 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/intellij/IntellijTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/intellij/IntellijTest.java @@ -17,6 +17,8 @@ import com.devonfw.tools.ide.os.SystemInfoMock; import com.devonfw.tools.ide.tool.ToolEdition; import com.devonfw.tools.ide.tool.ToolEditionAndVersion; +import com.devonfw.tools.ide.tool.ToolInstallation; +import com.devonfw.tools.ide.tool.claude.RecordingEnvironmentContext; import com.devonfw.tools.ide.version.VersionIdentifier; import com.github.tomakehurst.wiremock.junit5.WireMockTest; @@ -413,6 +415,25 @@ void testMissingExtraJavaTemplateFails() throws Exception { assertThat(jdkTableContent).contains("software/extra/java/client"); } + /** + * Tests if the environment variable {@code IDEA_PROPERTIES} is set to the path of the {@code idea.properties} file in the workspace. + */ + @Test + void testSetEnvironmentSetsIdeaProperties() { + + // arrange + Intellij commandlet = new Intellij(this.context); + Path dummy = context.getSoftwarePath().resolve("intellij"); + ToolInstallation installation = new ToolInstallation(dummy, dummy, dummy, VersionIdentifier.of("2023.3.3"), false); + RecordingEnvironmentContext environmentContext = new RecordingEnvironmentContext(); + + // act + commandlet.setEnvironment(environmentContext, installation, false); + + // assert + assertThat(environmentContext.set).containsEntry("IDEA_PROPERTIES", this.context.getWorkspacePath().resolve("idea.properties").toString()); + } + private void checkInstallation(IdeTestContext context) { Intellij commandlet = context.getCommandletManager().getCommandlet(Intellij.class); From 9ed3a22a92daa2ecdf0bb72f52309d6b27f5d577 Mon Sep 17 00:00:00 2001 From: JoelAdbu Date: Thu, 24 Sep 2026 15:24:27 +0200 Subject: [PATCH 26/66] #2458: Create url updater for antigravity (#2476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jörg Hohwiller --- .../tools/ide/url/tool/agy/AgyUrlUpdater.java | 68 ++ .../tools/ide/url/updater/UpdateManager.java | 25 +- .../ide/url/tool/agy/AgyUrlUpdaterTest.java | 62 + .../AgyUrlUpdater/agy-releases.json | 1086 +++++++++++++++++ 4 files changed, 1230 insertions(+), 11 deletions(-) create mode 100644 url-updater/src/main/java/com/devonfw/tools/ide/url/tool/agy/AgyUrlUpdater.java create mode 100644 url-updater/src/test/java/com/devonfw/tools/ide/url/tool/agy/AgyUrlUpdaterTest.java create mode 100644 url-updater/src/test/resources/integrationtest/AgyUrlUpdater/agy-releases.json diff --git a/url-updater/src/main/java/com/devonfw/tools/ide/url/tool/agy/AgyUrlUpdater.java b/url-updater/src/main/java/com/devonfw/tools/ide/url/tool/agy/AgyUrlUpdater.java new file mode 100644 index 00000000..e491ec35 --- /dev/null +++ b/url-updater/src/main/java/com/devonfw/tools/ide/url/tool/agy/AgyUrlUpdater.java @@ -0,0 +1,68 @@ +package com.devonfw.tools.ide.url.tool.agy; + +import com.devonfw.tools.ide.url.model.folder.UrlVersion; +import com.devonfw.tools.ide.url.updater.GithubUrlReleaseUpdater; + +/** + * {@link GithubUrlReleaseUpdater} for Anti-Gravity + */ +public class AgyUrlUpdater extends GithubUrlReleaseUpdater { + + /** + * The Constructor + */ + public AgyUrlUpdater() { + super(); + } + + /** + * Package-private constructor used for testing {@link AgyUrlUpdater} + * + * @param downloadBaseUrl mock url used for download base. + * @param versionBaseUrl mock url used for version base. + */ + AgyUrlUpdater(String downloadBaseUrl, String versionBaseUrl) { + super(downloadBaseUrl, versionBaseUrl); + } + + @Override + public String getTool() { + return "agy"; + } + + @Override + protected String getGithubOrganization() { + return "google-antigravity"; + } + + @Override + protected String getGithubRepository() { + return "antigravity-cli"; + } + + + @Override + protected void addVersion(UrlVersion urlVersion) { + String baseUrl = createGithubReleaseDownloadUrl("${version}", ""); + + doAddVersion(urlVersion, baseUrl + "agy_cli_windows_x64.zip", WINDOWS, X64); + doAddVersion(urlVersion, baseUrl + "agy_cli_windows_arm64.zip", WINDOWS, ARM64); + + doAddVersion(urlVersion, baseUrl + "agy_cli_mac_x64.tar.gz", MAC, X64); + doAddVersion(urlVersion, baseUrl + "agy_cli_mac_arm64.tar.gz", MAC, ARM64); + + doAddVersion(urlVersion, baseUrl + "agy_cli_linux_x64.tar.gz", LINUX, X64); + doAddVersion(urlVersion, baseUrl + "agy_cli_linux_arm64.tar.gz", LINUX, ARM64); + } + + @Override + public String getCpeVendor() { + return "google"; + } + + @Override + public String getCpeProduct() { + return "antigravity-cli"; + } + +} diff --git a/url-updater/src/main/java/com/devonfw/tools/ide/url/updater/UpdateManager.java b/url-updater/src/main/java/com/devonfw/tools/ide/url/updater/UpdateManager.java index 609a27a4..eaf047f6 100644 --- a/url-updater/src/main/java/com/devonfw/tools/ide/url/updater/UpdateManager.java +++ b/url-updater/src/main/java/com/devonfw/tools/ide/url/updater/UpdateManager.java @@ -13,6 +13,7 @@ import com.devonfw.tools.ide.url.model.folder.UrlRepository; import com.devonfw.tools.ide.url.model.folder.UrlVersion; import com.devonfw.tools.ide.url.model.report.UrlFinalReport; +import com.devonfw.tools.ide.url.tool.agy.AgyUrlUpdater; import com.devonfw.tools.ide.url.tool.androidstudio.AndroidStudioUrlUpdater; import com.devonfw.tools.ide.url.tool.aws.AwsUrlUpdater; import com.devonfw.tools.ide.url.tool.az.AzureUrlUpdater; @@ -85,16 +86,17 @@ public class UpdateManager extends AbstractProcessorWithTimeout { private final Map statusFiles = new HashMap<>(); private final List updaters = List.of( - new AndroidStudioUrlUpdater(), new AwsUrlUpdater(), new AzureUrlUpdater(), new ClaudeUrlUpdater(), new CopilotUrlUpdater(), new CorepackUrlUpdater(), - new DockerDesktopUrlUpdater(), new DotNetUrlUpdater(), new EclipseCppUrlUpdater(), new EclipseJeeUrlUpdater(), new EclipseJavaUrlUpdater(), - new GcLogAnalyzerUrlUpdater(), new GCloudUrlUpdater(), new GcViewerUrlUpdater(), new GhUrlUpdater(), new GoUrlUpdater(), new GraalVmCommunityUpdater(), - new GraalVmOracleUrlUpdater(), new GradleUrlUpdater(), new HelmUrlUpdater(), new InsoUrlUpdater(), new IntellijUrlUpdater(), new JasyptUrlUpdater(), - new JavaAzulUrlUpdater(), new JavaUrlUpdater(), new JenkinsUrlUpdater(), new JmcUrlUpdater(), new KotlincUrlUpdater(), new KotlincNativeUrlUpdater(), - new LazyDockerUrlUpdater(), new MvnUrlUpdater(), new MvndUrlUpdater(), new NgUrlUpdater(), new NodeUrlUpdater(), new NpmUrlUpdater(), - new ObsidianUrlUpdater(), new OcUrlUpdater(), new PgAdminUrlUpdater(), new PipUrlUpdater(), new PycharmUrlUpdater(), new QuarkusUrlUpdater(), - new RubyUrlUpdater(), new RubyJdxUrlUpdater(), new RustUrlUpdater(), new DockerRancherDesktopUrlUpdater(), new SonarUrlUpdater(), - new SquirrelSqlUrlUpdater(), new SoapUiUrlUpdater(), new TerraformUrlUpdater(), new TomcatUrlUpdater(), new UvUrlUpdater(), new VsCodeUrlUpdater(), - new VsCodiumUrlUpdater()); + new AgyUrlUpdater(), new AndroidStudioUrlUpdater(), new AwsUrlUpdater(), new AzureUrlUpdater(), + new ClaudeUrlUpdater(), new CopilotUrlUpdater(), new CorepackUrlUpdater(), new DockerDesktopUrlUpdater(), new DotNetUrlUpdater(), + new EclipseCppUrlUpdater(), new EclipseJeeUrlUpdater(), new EclipseJavaUrlUpdater(), new GcLogAnalyzerUrlUpdater(), new GCloudUrlUpdater(), + new GcViewerUrlUpdater(), new GhUrlUpdater(), new GoUrlUpdater(), new GraalVmCommunityUpdater(), new GraalVmOracleUrlUpdater(), new GradleUrlUpdater(), + new HelmUrlUpdater(), new InsoUrlUpdater(), new IntellijUrlUpdater(), new JasyptUrlUpdater(), new JavaAzulUrlUpdater(), new JavaUrlUpdater(), + new JenkinsUrlUpdater(), new JmcUrlUpdater(), new KotlincUrlUpdater(), new KotlincNativeUrlUpdater(), new LazyDockerUrlUpdater(), new MvnUrlUpdater(), + new MvndUrlUpdater(), new NgUrlUpdater(), new NodeUrlUpdater(), new NpmUrlUpdater(), new ObsidianUrlUpdater(), new OcUrlUpdater(), + new PgAdminUrlUpdater(), new PipUrlUpdater(), new PycharmUrlUpdater(), new QuarkusUrlUpdater(), new RubyUrlUpdater(), new RubyJdxUrlUpdater(), + new RustUrlUpdater(), new DockerRancherDesktopUrlUpdater(), new SonarUrlUpdater(), new SquirrelSqlUrlUpdater(), new SoapUiUrlUpdater(), + new TerraformUrlUpdater(), new TomcatUrlUpdater(), new UvUrlUpdater(), new VsCodeUrlUpdater(), new VsCodiumUrlUpdater() + ); /** * The constructor. @@ -203,7 +205,8 @@ public UrlRepository getUrlRepository() { /** * @param urlVersion the {@link UrlVersion} to get the {@link UrlStatusFile} for. - * @param create {@code true} to create the {@link UrlStatusFile} if the {@code status.json} does not exist yet, {@code false} to return {@code null} instead. + * @param create {@code true} to create the {@link UrlStatusFile} if the {@code status.json} does not exist yet, {@code false} to return {@code null} + * instead. * @return the cached {@link UrlStatusFile} for the given {@link UrlVersion} or {@code null}. */ public UrlStatusFile getStatusFile(UrlVersion urlVersion, boolean create) { diff --git a/url-updater/src/test/java/com/devonfw/tools/ide/url/tool/agy/AgyUrlUpdaterTest.java b/url-updater/src/test/java/com/devonfw/tools/ide/url/tool/agy/AgyUrlUpdaterTest.java new file mode 100644 index 00000000..b991ba7c --- /dev/null +++ b/url-updater/src/test/java/com/devonfw/tools/ide/url/tool/agy/AgyUrlUpdaterTest.java @@ -0,0 +1,62 @@ +package com.devonfw.tools.ide.url.tool.agy; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.any; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; + +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.devonfw.tools.ide.url.model.folder.UrlRepository; +import com.devonfw.tools.ide.url.updater.AbstractUrlUpdaterTest; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; + +/** + * Test of {@link AgyUrlUpdater}. + */ +@WireMockTest +public class AgyUrlUpdaterTest extends AbstractUrlUpdaterTest { + + /** + * Test of {@link AgyUrlUpdater} for the creation of download URLs and checksums + * + * @param tempDir Path to temporary directory + * @param wmRuntimeInfo the {@link WireMockRuntimeInfo}. + */ + @Test + void testAgyUrlUpdater(@TempDir Path tempDir, WireMockRuntimeInfo wmRuntimeInfo) { + //arrange + stubFor(get(urlMatching("/repos/google-antigravity/antigravity-cli/releases")) + .willReturn(aResponse() + .withStatus(200) + .withBody(readAndResolve(PATH_INTEGRATION_TEST.resolve("AgyUrlUpdater") + .resolve("agy-releases.json"), wmRuntimeInfo)))); + + stubFor(any(urlMatching( + "/google-antigravity/antigravity-cli/releases/download/\\d+\\.\\d+\\.\\d+/agy_cli_(windows|mac|linux)_(x64|arm64)\\.(zip|tar\\.gz)")) + .willReturn(aResponse() + .withStatus(200) + .withBody(DOWNLOAD_CONTENT))); + + UrlRepository urlRepository = UrlRepository.load(tempDir); + AgyUrlUpdater updater = new AgyUrlUpdater(wmRuntimeInfo.getHttpBaseUrl(), wmRuntimeInfo.getHttpBaseUrl()); + + // act + update(updater, urlRepository); + + // assert + List expectedPlatforms = List.of("windows_x64", "windows_arm64", "mac_x64", "mac_arm64", "linux_x64", "linux_arm64"); + Path agyDir = tempDir.resolve("agy").resolve("agy"); + assertUrlVersion(agyDir.resolve("1.2.3"), expectedPlatforms); + assertUrlVersion(agyDir.resolve("1.2.2"), expectedPlatforms); + assertUrlVersion(agyDir.resolve("1.2.1"), expectedPlatforms); + assertUrlVersion(agyDir.resolve("1.2.0"), expectedPlatforms); + + } +} diff --git a/url-updater/src/test/resources/integrationtest/AgyUrlUpdater/agy-releases.json b/url-updater/src/test/resources/integrationtest/AgyUrlUpdater/agy-releases.json new file mode 100644 index 00000000..366ad5b4 --- /dev/null +++ b/url-updater/src/test/resources/integrationtest/AgyUrlUpdater/agy-releases.json @@ -0,0 +1,1086 @@ +[ + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/388820804", + "assets_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/388820804/assets", + "upload_url": "https://uploads.github.com/repos/google-antigravity/antigravity-cli/releases/388820804/assets{?name,label}", + "html_url": "https://github.com/google-antigravity/antigravity-cli/releases/tag/1.2.3", + "id": 388820804, + "author": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "node_id": "RE_kwDOScoSV84XLO9E", + "tag_name": "1.2.3", + "target_commitish": "main", + "name": "1.2.3", + "draft": false, + "immutable": false, + "prerelease": false, + "created_at": "2026-09-15T02:03:06Z", + "updated_at": "2026-09-15T02:03:18Z", + "published_at": "2026-09-15T02:03:04Z", + "assets": [ + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/564665047", + "id": 564665047, + "node_id": "RA_kwDOScoSV84hqBrX", + "name": "agy_cli_linux_arm64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 54306993, + "digest": "sha256:e500d8b5d61bf8420cd397a1dabfa80899817b850b57982a1f5d4742be2b5d05", + "download_count": 39, + "created_at": "2026-09-15T02:02:57Z", + "updated_at": "2026-09-15T02:03:01Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.3/agy_cli_linux_arm64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/564665049", + "id": 564665049, + "node_id": "RA_kwDOScoSV84hqBrZ", + "name": "agy_cli_linux_x64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 57891892, + "digest": "sha256:57afb34f2a4be9296beb477e600761b6ac7401eb3a54a64a0014d573b7fc3af4", + "download_count": 274, + "created_at": "2026-09-15T02:02:57Z", + "updated_at": "2026-09-15T02:03:01Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.3/agy_cli_linux_x64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/564665044", + "id": 564665044, + "node_id": "RA_kwDOScoSV84hqBrU", + "name": "agy_cli_mac_arm64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 50217630, + "digest": "sha256:c244ec966f5d8c22d845117332a6858f3abaf0fb5c8058e337d51b77b2c93e50", + "download_count": 68, + "created_at": "2026-09-15T02:02:57Z", + "updated_at": "2026-09-15T02:03:01Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.3/agy_cli_mac_arm64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/564665046", + "id": 564665046, + "node_id": "RA_kwDOScoSV84hqBrW", + "name": "agy_cli_mac_x64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 55184535, + "digest": "sha256:5384ba8533bc40899526e4ee1d0ac6289e18503ccd28ca49ca5765fd3efea15e", + "download_count": 39, + "created_at": "2026-09-15T02:02:57Z", + "updated_at": "2026-09-15T02:03:01Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.3/agy_cli_mac_x64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/564665158", + "id": 564665158, + "node_id": "RA_kwDOScoSV84hqBtG", + "name": "agy_cli_windows_arm64.zip", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/zip", + "state": "uploaded", + "size": 49471592, + "digest": "sha256:b41b4d5e1c7b96c6fb95e3142533a20614eb9d2c4e48ad4e284375c9459a9789", + "download_count": 5, + "created_at": "2026-09-15T02:03:01Z", + "updated_at": "2026-09-15T02:03:04Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.3/agy_cli_windows_arm64.zip" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/564665050", + "id": 564665050, + "node_id": "RA_kwDOScoSV84hqBra", + "name": "agy_cli_windows_x64.zip", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/zip", + "state": "uploaded", + "size": 54292962, + "digest": "sha256:be6614b216cfa9ce002c94d6aaf9f96535b5d8e3480a96f45414ae2f63d3aa34", + "download_count": 51, + "created_at": "2026-09-15T02:02:57Z", + "updated_at": "2026-09-15T02:03:01Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.3/agy_cli_windows_x64.zip" + } + ], + "tarball_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/tarball/1.2.3", + "zipball_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/zipball/1.2.3", + "body": "- Added the `/copy btw` slash subcommand to copy the full text of the active `/btw` side-question response to the clipboard even when the response card is collapsed or scrolled, along with argument ghost hints when typing `/copy`.\n- Improved tool-calling fidelity by translating per-request tool-choice constraints (`any`, `required`, and named functions) into native backend function-calling configurations.\n- Fixed the `/hooks` command and hook inspection utilities omitting hooks bundled inside enabled plugins when listing active `hooks.json` configurations.\n- Fixed custom subagents created with `enable_mcp_tools: true` receiving an empty MCP server list instead of inheriting the parent agent's configured MCP servers, and fixed declarative subagent configurations failing to resolve relative config file paths (`relative_path_to_config`) against the parent agent's directory.\n- Fixed proactive feedback prompts triggering prematurely during the first few turns of a session or on transient, recovered tool errors.\n", + "reactions": { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/388820804/reactions", + "total_count": 6, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 1, + "confused": 0, + "heart": 3, + "rocket": 1, + "eyes": 1 + } + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/387445796", + "assets_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/387445796/assets", + "upload_url": "https://uploads.github.com/repos/google-antigravity/antigravity-cli/releases/387445796/assets{?name,label}", + "html_url": "https://github.com/google-antigravity/antigravity-cli/releases/tag/1.2.2", + "id": 387445796, + "author": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "node_id": "RE_kwDOScoSV84XF_Qk", + "tag_name": "1.2.2", + "target_commitish": "main", + "name": "1.2.2", + "draft": false, + "immutable": false, + "prerelease": false, + "created_at": "2026-09-12T03:51:10Z", + "updated_at": "2026-09-12T03:51:14Z", + "published_at": "2026-09-12T03:51:08Z", + "assets": [ + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/558553053", + "id": 558553053, + "node_id": "RA_kwDOScoSV84hStfd", + "name": "agy_cli_linux_arm64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 54016481, + "digest": "sha256:00fc5cb50cd714b81cdf1298fcc90e63a59e3e564a4f8076454ab8457d36e6eb", + "download_count": 335, + "created_at": "2026-09-12T03:51:01Z", + "updated_at": "2026-09-12T03:51:05Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.2/agy_cli_linux_arm64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/558553052", + "id": 558553052, + "node_id": "RA_kwDOScoSV84hStfc", + "name": "agy_cli_linux_x64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 57596853, + "digest": "sha256:2cfa5c9a4a1edd96db6d4058f34970be60d3bcacda866e2bdce6aefb2451b48e", + "download_count": 1761, + "created_at": "2026-09-12T03:51:01Z", + "updated_at": "2026-09-12T03:51:05Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.2/agy_cli_linux_x64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/558553056", + "id": 558553056, + "node_id": "RA_kwDOScoSV84hStfg", + "name": "agy_cli_mac_arm64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 49929659, + "digest": "sha256:f90ff6094a196f1be3854ac45d999a542d47ec66a1513aa882a8505b947b9a0f", + "download_count": 646, + "created_at": "2026-09-12T03:51:01Z", + "updated_at": "2026-09-12T03:51:05Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.2/agy_cli_mac_arm64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/558553050", + "id": 558553050, + "node_id": "RA_kwDOScoSV84hStfa", + "name": "agy_cli_mac_x64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 54877358, + "digest": "sha256:2b44ed726a73a0ef0a39956c80e07b5898c0b4fe21f8ecd383be74f1c0b65e5d", + "download_count": 310, + "created_at": "2026-09-12T03:51:01Z", + "updated_at": "2026-09-12T03:51:06Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.2/agy_cli_mac_x64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/558553154", + "id": 558553154, + "node_id": "RA_kwDOScoSV84hSthC", + "name": "agy_cli_windows_arm64.zip", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/zip", + "state": "uploaded", + "size": 49127588, + "digest": "sha256:00c4d0cc4d05372588eb4a016147e4a3d4a5b00ebdc692eaf46042e8de3cf6fd", + "download_count": 33, + "created_at": "2026-09-12T03:51:05Z", + "updated_at": "2026-09-12T03:51:08Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.2/agy_cli_windows_arm64.zip" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/558553054", + "id": 558553054, + "node_id": "RA_kwDOScoSV84hStfe", + "name": "agy_cli_windows_x64.zip", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/zip", + "state": "uploaded", + "size": 53917493, + "digest": "sha256:50ce727a034980d341ee03773d2b81a78fb3c7897ac2ff138f55db88cead4c03", + "download_count": 492, + "created_at": "2026-09-12T03:51:01Z", + "updated_at": "2026-09-12T03:51:05Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.2/agy_cli_windows_x64.zip" + } + ], + "tarball_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/tarball/1.2.2", + "zipball_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/zipball/1.2.2", + "body": "- Improved the startup warning for deprecated `unsandboxed` permission rules across CLI, shared, and project configuration files to list each affected file path, up to five offending rules, and step-by-step instructions for migrating them to `command` rules.\n- Improved `/resume` startup responsiveness when opening a large conversation history with a cold or stale summary cache.\n- Fixed MCP servers bundled inside plugins colliding with each other or with user-configured servers in `mcp_config.json` when they shared the same server name by automatically namespacing plugin MCP servers as `_`.\n- Fixed a memory leak where opening or scanning conversations left background step-cache eviction goroutines running for the rest of the session, significantly reducing memory usage after opening `/resume` or switching conversations.\n- Fixed `view_file` attempting to parse non-UTF-8 binary files as text or loading files larger than 100 MB into the model context; unsupported binary formats and oversized files are now rejected with a clear error before overflowing the context window.\n- Fixed Gemini API (`GEMINI_API_KEY`) sessions dropping model thinking blocks from prior turns on subsequent user messages and failing to propagate thought signatures on text and thought parts.\n- Fixed artifact review failing to trigger when a directory name above `.gemini/` matched a skipped path component such as `scratch`, prevented conversation forks and snapshot reverts from copying internal `.system_generated/subagents` and `.system_generated/worktrees` directories, and cleaned up subagent metadata records when deleting a conversation.\n- Fixed deleted conversations being recreated as empty, schema-less SQLite database files when background queries reconnected after deletion.\n- Fixed conversations launched without a workspace folder inheriting workspace paths and customizations from other open sessions.\n", + "reactions": { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/387445796/reactions", + "total_count": 10, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 2, + "confused": 0, + "heart": 2, + "rocket": 6, + "eyes": 0 + } + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/386833647", + "assets_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/386833647/assets", + "upload_url": "https://uploads.github.com/repos/google-antigravity/antigravity-cli/releases/386833647/assets{?name,label}", + "html_url": "https://github.com/google-antigravity/antigravity-cli/releases/tag/1.2.1", + "id": 386833647, + "author": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "node_id": "RE_kwDOScoSV84XDpzv", + "tag_name": "1.2.1", + "target_commitish": "main", + "name": "1.2.1", + "draft": false, + "immutable": false, + "prerelease": false, + "created_at": "2026-09-11T06:46:50Z", + "updated_at": "2026-09-11T06:46:55Z", + "published_at": "2026-09-11T06:46:48Z", + "assets": [ + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/556672095", + "id": 556672095, + "node_id": "RA_kwDOScoSV84hLiRf", + "name": "agy_cli_linux_arm64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 53888431, + "digest": "sha256:882d9160972d09a721422baa554999ad969bc9b3fa680e866a981398f937f19f", + "download_count": 118, + "created_at": "2026-09-11T06:46:41Z", + "updated_at": "2026-09-11T06:46:45Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.1/agy_cli_linux_arm64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/556672096", + "id": 556672096, + "node_id": "RA_kwDOScoSV84hLiRg", + "name": "agy_cli_linux_x64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 57458208, + "digest": "sha256:6a2c53db6c681fc114f9a1e499e7b4771357ab2852242e56acbd43197d4807f9", + "download_count": 785, + "created_at": "2026-09-11T06:46:41Z", + "updated_at": "2026-09-11T06:46:45Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.1/agy_cli_linux_x64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/556672093", + "id": 556672093, + "node_id": "RA_kwDOScoSV84hLiRd", + "name": "agy_cli_mac_arm64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 49797347, + "digest": "sha256:b80425e10a7b92f20679eee5df3bb18e3f9154b3653a373fad47e2f72614248e", + "download_count": 257, + "created_at": "2026-09-11T06:46:41Z", + "updated_at": "2026-09-11T06:46:45Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.1/agy_cli_mac_arm64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/556672092", + "id": 556672092, + "node_id": "RA_kwDOScoSV84hLiRc", + "name": "agy_cli_mac_x64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 54716166, + "digest": "sha256:c77c6f782c6f79dc4ad831e833ce71249f90cfddf8e2e4579719c51709f08a16", + "download_count": 108, + "created_at": "2026-09-11T06:46:41Z", + "updated_at": "2026-09-11T06:46:45Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.1/agy_cli_mac_x64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/556672233", + "id": 556672233, + "node_id": "RA_kwDOScoSV84hLiTp", + "name": "agy_cli_windows_arm64.zip", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/zip", + "state": "uploaded", + "size": 48990159, + "digest": "sha256:208378db43a1f386acf54657d641237afa31683d4fb8def1c89c0d67e80577b5", + "download_count": 12, + "created_at": "2026-09-11T06:46:45Z", + "updated_at": "2026-09-11T06:46:48Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.1/agy_cli_windows_arm64.zip" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/556672091", + "id": 556672091, + "node_id": "RA_kwDOScoSV84hLiRb", + "name": "agy_cli_windows_x64.zip", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/zip", + "state": "uploaded", + "size": 53754147, + "digest": "sha256:233c86abbbf589b9f4c983294eaee4767fbbdc7d469ebfd2638bc968a4cea216", + "download_count": 169, + "created_at": "2026-09-11T06:46:41Z", + "updated_at": "2026-09-11T06:46:45Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.1/agy_cli_windows_x64.zip" + } + ], + "tarball_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/tarball/1.2.1", + "zipball_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/zipball/1.2.1", + "body": "- Added support for `excludeDefaultComponents: true` in custom agent Markdown frontmatter, allowing custom agents to opt out of default prompt sections and built-in tools while preserving post-invocation hooks.\n- Improved model API error resilience and diagnostics: transient `genai.APIError` failures (`502`, `503`, `504`, per-minute `429` rate limits, and mid-stream interruptions) automatically retry in-process with exponential backoff while preserving completed tool call outputs, and unrecovered `503` and `429` responses surface clear user-facing error messages.\n- Improved MCP and provider tool schema validation to preserve open object schemas (such as `{\"type\": \"object\"}` or explicit `additionalProperties: true`) instead of rejecting undeclared arguments on schemas that allow them.\n- Improved per-turn responsiveness and reduced memory usage when opening large conversations.\n- Fixed `--continue` starting a brand-new conversation when launched from a subdirectory, after a crash, or while another session is open in the same workspace; it now falls back to the most recent non-empty conversation in the current workspace or its parent/child directories.\n- Fixed full-screen blank flashes in inline mode when content first pushes to scrollback, at the end of a turn, or when clearing the screen with `Ctrl+L`.\n- Fixed remote companion UIs connected to an interactive CLI session via Remote Control displaying an unauthenticated sign-in screen instead of the active session's signed-in state.\n- Fixed the status line reporting the terminal sandbox as disabled when the session was launched with the `--sandbox` command-line flag.\n- Fixed image zoom keys (`Ctrl+=` and `Ctrl+-`) and the footer zoom hint appearing in the artifact viewer when a Mermaid diagram is rendered in ASCII mode instead of as a Kitty image.\n", + "reactions": { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/386833647/reactions", + "total_count": 5, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 1, + "confused": 0, + "heart": 3, + "rocket": 0, + "eyes": 1 + } + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/385950232", + "assets_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/385950232/assets", + "upload_url": "https://uploads.github.com/repos/google-antigravity/antigravity-cli/releases/385950232/assets{?name,label}", + "html_url": "https://github.com/google-antigravity/antigravity-cli/releases/tag/1.2.0", + "id": 385950232, + "author": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "node_id": "RE_kwDOScoSV84XASIY", + "tag_name": "1.2.0", + "target_commitish": "main", + "name": "1.2.0", + "draft": false, + "immutable": false, + "prerelease": false, + "created_at": "2026-09-10T01:43:31Z", + "updated_at": "2026-09-10T01:43:35Z", + "published_at": "2026-09-10T01:43:29Z", + "assets": [ + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/553988479", + "id": 553988479, + "node_id": "RA_kwDOScoSV84hBTF_", + "name": "agy_cli_linux_arm64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 53647388, + "digest": "sha256:0a8e61f6548865029c4238b9310c686b7a733db5864cae619656abdf48090594", + "download_count": 100, + "created_at": "2026-09-10T01:43:22Z", + "updated_at": "2026-09-10T01:43:26Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.0/agy_cli_linux_arm64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/553988482", + "id": 553988482, + "node_id": "RA_kwDOScoSV84hBTGC", + "name": "agy_cli_linux_x64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 57224342, + "digest": "sha256:d9bfee1ae6e4329562cb87da1f5fc3c886d18594837e73e25c3aae00a49499b9", + "download_count": 1052, + "created_at": "2026-09-10T01:43:22Z", + "updated_at": "2026-09-10T01:43:26Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.0/agy_cli_linux_x64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/553988478", + "id": 553988478, + "node_id": "RA_kwDOScoSV84hBTF-", + "name": "agy_cli_mac_arm64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 49578312, + "digest": "sha256:8fee3c120142490f2eddd286ff7d532b3e660c92d17e60a3612cc0ad1122478f", + "download_count": 275, + "created_at": "2026-09-10T01:43:22Z", + "updated_at": "2026-09-10T01:43:27Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.0/agy_cli_mac_arm64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/553988480", + "id": 553988480, + "node_id": "RA_kwDOScoSV84hBTGA", + "name": "agy_cli_mac_x64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 54477008, + "digest": "sha256:79799297510167ed46ab40add47f2360fe2bb08fc7495f1de02452dd8a109eb7", + "download_count": 137, + "created_at": "2026-09-10T01:43:22Z", + "updated_at": "2026-09-10T01:43:26Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.0/agy_cli_mac_x64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/553988591", + "id": 553988591, + "node_id": "RA_kwDOScoSV84hBTHv", + "name": "agy_cli_windows_arm64.zip", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/zip", + "state": "uploaded", + "size": 48769448, + "digest": "sha256:139d665de51a5c04b42dfdba8f7829f1e0cebaaca0e8ca069c205a9b19ec6dfc", + "download_count": 11, + "created_at": "2026-09-10T01:43:26Z", + "updated_at": "2026-09-10T01:43:29Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.0/agy_cli_windows_arm64.zip" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/553988481", + "id": 553988481, + "node_id": "RA_kwDOScoSV84hBTGB", + "name": "agy_cli_windows_x64.zip", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/zip", + "state": "uploaded", + "size": 53523622, + "digest": "sha256:27a2096a255c829acc0ca98e6305226e23016bfd69298f60e1060ecff983f907", + "download_count": 234, + "created_at": "2026-09-10T01:43:22Z", + "updated_at": "2026-09-10T01:43:26Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.0/agy_cli_windows_x64.zip" + } + ], + "tarball_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/tarball/1.2.0", + "zipball_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/zipball/1.2.0", + "body": "- Added the `remote-control start`, `remote-control status`, and `remote-control stop` subcommands to run the CLI as a background service registered with your operating system's service manager so your machine stays reachable across logouts and reboots; `remote-control start --name

+ * This constructor enables "design for testability" (see {@code documentation/contributing/junit-testing.adoc}): by injecting an {@link IdeContext} (for + * example an {@link com.devonfw.tools.ide.context.IdeTestContext}) the service can be isolated from the real user home and the environment of the + * machine running the test. + * + * @param context the {@link IdeContext} to use for resolving the user environment variables. + * @param locale the preferred locale, or {@code null} to use persisted or default locale. + */ + public NlsService(IdeContext context, Locale locale) { + + this.context = Objects.requireNonNull(context, "context must not be null"); initialize(locale); } @@ -325,7 +350,9 @@ private void persistLocale(Locale localeToPersist) { EnvironmentVariables environmentVariables = getUserEnvironmentVariables(); try { - String ideOptions = environmentVariables.get(IDE_OPTIONS); + // read only from the user configuration file, not from the machine-wide environment (see getFlat): the persisted GUI locale must not + // be influenced by a global IDE_OPTIONS environment variable + String ideOptions = environmentVariables.getFlat(IDE_OPTIONS); String userLangFlag = "-Duser.language=" + localeToPersist.toLanguageTag(); String updated = updateLocale(ideOptions, userLangFlag); environmentVariables.set(IDE_OPTIONS, updated); @@ -361,7 +388,8 @@ private EnvironmentVariables getUserEnvironmentVariables() { private EnvironmentVariables createUserEnvironmentVariables() { - IdeContext context = new IdeContextConsole(); + // if no context was injected (see NlsService(Locale)) we create a console context lazily here, so constructing the service has no side effects + IdeContext context = (this.context != null) ? this.context : new IdeContextConsole(); EnvironmentVariables userVariables = context.getVariables().getByType(EnvironmentVariablesType.USER); if (userVariables == null) { throw new IllegalStateException("Failed to resolve USER environment variables from IDE context"); diff --git a/gui/src/test/java/com/devonfw/ide/gui/nls/NlsServiceTest.java b/gui/src/test/java/com/devonfw/ide/gui/nls/NlsServiceTest.java index adb9cac1..f187dbf5 100644 --- a/gui/src/test/java/com/devonfw/ide/gui/nls/NlsServiceTest.java +++ b/gui/src/test/java/com/devonfw/ide/gui/nls/NlsServiceTest.java @@ -1,6 +1,5 @@ package com.devonfw.ide.gui.nls; -import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.params.provider.Arguments.arguments; import java.io.IOException; @@ -20,7 +19,6 @@ import java.util.jar.JarOutputStream; import java.util.stream.Stream; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -28,31 +26,61 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.environment.IdeSystemTestImpl; + /** * Tests for {@link NlsService} - verifies locale switching, bundle loading, and fallback behavior. + * + *

+ * This test is isolated from the machine it runs on (see {@code documentation/contributing/junit-testing.adoc}): the {@link NlsService} is created with an + * isolated {@link IdeTestContext} whose system environment and user home are stubbed. The user home is a per-test {@link TempDir @TempDir} created for + * every test (and cleaned up afterwards), so the persisted GUI locale is written to a throw-away {@code ~/.ide/ide.properties} and can never leak between + * tests or be influenced by the developer's real user home, the {@code IDE_OPTIONS} environment variable or any other global configuration. */ -public class NlsServiceTest { +public class NlsServiceTest extends AbstractIdeContextTest { + + /** The name of the {@code ide.properties} file in the user home. */ + private static final String IDE_PROPERTIES_FILE = "ide.properties"; + /** + * The throw-away user home of the isolated {@link IdeTestContext}. A fresh directory is created for every test so the persisted + * {@code ~/.ide/ide.properties} never leaks between tests and the test works on any operating system. + */ @TempDir - Path tempUserHome; + Path userHome; - private String originalUserHome; + /** The isolated {@link IdeTestContext} that the {@link NlsService} under test persists into. */ + private IdeTestContext context; + /** + * Prepares an isolated {@link IdeTestContext} for every test so the test is not influenced by global configuration. + */ @BeforeEach public void setUp() { - this.originalUserHome = System.getProperty("user.home"); - System.setProperty("user.home", this.tempUserHome.toString()); + this.context = new IdeTestContext(); + // isolate from the real System: a decoupled, empty environment (no IDE_OPTIONS) so the persisted selection is not influenced by the machine environment + this.context.setSystem(new IdeSystemTestImpl()); + // point the isolated context at a fresh, throw-away user home (created anew per test) instead of the developer's real home + this.context.setUserHome(this.userHome); } - @AfterEach - public void tearDown() { + /** + * @return the {@code ~/.ide/ide.properties} file of the isolated {@link #context}. + */ + private Path getUserIdeProperties() { - if (this.originalUserHome == null) { - System.clearProperty("user.home"); - } else { - System.setProperty("user.home", this.originalUserHome); - } + return this.userHome.resolve(".ide").resolve(IDE_PROPERTIES_FILE); + } + + /** + * Creates a {@link NlsService} that persists the locale into the isolated {@link #context} instead of the real user home. + */ + private NlsService newService(Locale locale) { + + return new NlsService(this.context, locale); } @ParameterizedTest @@ -82,7 +110,7 @@ static Stream testGetInstanceWithLocaleProvider() { @Test public void testSetLocale() { - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); service.setLocale(Locale.GERMAN); assertThat(service.getLocale().getLanguage()).isEqualTo("de"); @@ -93,7 +121,7 @@ public void testSetLocale() { @Test public void testAllLocalizationBundlesContainExactlyTheEnglishKeys() throws IOException { - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); Set englishKeys = loadBundleProperties(Locale.ENGLISH).stringPropertyNames(); for (Locale locale : service.getAvailableLocales()) { @@ -119,7 +147,7 @@ public void testAllLocalizationBundlesContainExactlyTheEnglishKeys() throws IOEx @ParameterizedTest @MethodSource("testLanguageDisplayShowsLocaleNameProvider") public void testLanguageDisplayShowsLocaleName(Locale serviceLocale, Locale displayLocale, String expectedString) { - NlsService service = new NlsService(serviceLocale); + NlsService service = newService(serviceLocale); assertThat(service.getLanguageDisplayName(displayLocale)).isEqualTo(expectedString); } @@ -138,7 +166,7 @@ static Stream testLanguageDisplayShowsLocaleNameProvider() { @Test public void testLocaleChangeListenerIsInvokedAndCanBeRemoved() { - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); AtomicInteger counter = new AtomicInteger(); Runnable listener = counter::incrementAndGet; @@ -154,74 +182,77 @@ public void testLocaleChangeListenerIsInvokedAndCanBeRemoved() { @Test public void testSetLocalePersistsSelectionInUserHomeIdeProperties() throws IOException { - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); service.setLocale(Locale.GERMAN); - Path propertiesFile = this.tempUserHome.resolve(".ide").resolve("ide.properties"); + Path propertiesFile = getUserIdeProperties(); assertThat(Files.exists(propertiesFile)).isTrue(); - Properties properties = new Properties(); - try (InputStream inputStream = Files.newInputStream(propertiesFile)) { - properties.load(inputStream); - } - assertThat(properties.getProperty("IDE_OPTIONS")).isEqualTo("-Duser.language=de"); + Properties properties = loadUserIdeProperties(propertiesFile); + assertThat(properties.getProperty(NlsService.IDE_OPTIONS)).isEqualTo("-Duser.language=de"); + } + + @Test + public void testSetLocaleIsNotInfluencedByIdeOptionsEnvironmentVariable() throws IOException { + + // simulate a machine where IDE_OPTIONS is set globally (e.g. SSL trust store flags): the persisted selection must not be polluted by it + this.context.getSystem().setEnv(NlsService.IDE_OPTIONS, + "-Djavax.net.ssl.trustStore=C:Usershohwille.ide ruststore ruststore.p12 -Djavax.net.ssl.trustStorePassword=changeit"); + + NlsService service = newService(Locale.ENGLISH); + service.setLocale(Locale.GERMAN); + + Path propertiesFile = getUserIdeProperties(); + assertThat(Files.exists(propertiesFile)).isTrue(); + + Properties properties = loadUserIdeProperties(propertiesFile); + // only the language selection is persisted - the global IDE_OPTIONS environment variable must not leak into the user configuration + assertThat(properties.getProperty(NlsService.IDE_OPTIONS)).isEqualTo("-Duser.language=de"); } @Test public void testPersistLocaleUpdatesExistingUserLangInIdeOptions() throws IOException { - Path userIdeFolder = this.tempUserHome.resolve(".ide"); - Files.createDirectories(userIdeFolder); - Path userProperties = userIdeFolder.resolve("ide.properties"); + Path userProperties = getUserIdeProperties(); + this.context.getFileAccess().mkdirs(userProperties.getParent()); Files.writeString(userProperties, "IDE_OPTIONS=-Duser.language=de\n"); - NlsService service = new NlsService(null); + NlsService service = newService(null); service.setLocale(Locale.ENGLISH); - Properties properties = new Properties(); - try (InputStream inputStream = Files.newInputStream(userProperties)) { - properties.load(inputStream); - } + Properties properties = loadUserIdeProperties(userProperties); - assertThat(properties.getProperty("IDE_OPTIONS")).isEqualTo("-Duser.language=en"); + assertThat(properties.getProperty(NlsService.IDE_OPTIONS)).isEqualTo("-Duser.language=en"); } @Test public void testPersistLocaleAppendsWhenIdeOptionsHasOtherOptions() throws IOException { - Path userIdeFolder = this.tempUserHome.resolve(".ide"); - Files.createDirectories(userIdeFolder); - Path userProperties = userIdeFolder.resolve("ide.properties"); + Path userProperties = getUserIdeProperties(); + this.context.getFileAccess().mkdirs(userProperties.getParent()); Files.writeString(userProperties, "IDE_OPTIONS=-Dfoo=bar\n"); - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); service.setLocale(Locale.GERMAN); - Properties properties = new Properties(); - try (InputStream inputStream = Files.newInputStream(userProperties)) { - properties.load(inputStream); - } + Properties properties = loadUserIdeProperties(userProperties); - assertThat(properties.getProperty("IDE_OPTIONS")).isEqualTo("-Dfoo=bar -Duser.language=de"); + assertThat(properties.getProperty(NlsService.IDE_OPTIONS)).isEqualTo("-Dfoo=bar -Duser.language=de"); } @Test public void testPersistLocalePreservesUnrelatedIdeOptions() throws IOException { - Path userIdeFolder = this.tempUserHome.resolve(".ide"); - Files.createDirectories(userIdeFolder); - Path userProperties = userIdeFolder.resolve("ide.properties"); + Path userProperties = getUserIdeProperties(); + this.context.getFileAccess().mkdirs(userProperties.getParent()); Files.writeString(userProperties, "IDE_OPTIONS=-Dfoo=bar -Duser.language=de\n"); - NlsService service = new NlsService(null); + NlsService service = newService(null); service.setLocale(Locale.ENGLISH); - Properties properties = new Properties(); - try (InputStream inputStream = Files.newInputStream(userProperties)) { - properties.load(inputStream); - } + Properties properties = loadUserIdeProperties(userProperties); - String ideOptions = properties.getProperty("IDE_OPTIONS"); + String ideOptions = properties.getProperty(NlsService.IDE_OPTIONS); assertThat(ideOptions).contains("-Dfoo=bar"); assertThat(ideOptions).contains("-Duser.language=en"); assertThat(ideOptions).doesNotContain("-Duser.language=de"); @@ -230,7 +261,7 @@ public void testPersistLocalePreservesUnrelatedIdeOptions() throws IOException { @Test public void testNoEmptyTranslations() throws IOException { - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); for (Locale locale : service.getAvailableLocales()) { Properties props = loadBundleProperties(locale); @@ -245,7 +276,7 @@ public void testNoEmptyTranslations() throws IOException { @Test public void testGetAvailableLocalesAlwaysContainsEnglish() { - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); assertThat(service.getAvailableLocales()).contains(Locale.ENGLISH); } @@ -253,7 +284,7 @@ public void testGetAvailableLocalesAlwaysContainsEnglish() { @Test public void testGetAvailableLocalesDetectsExistingBundleFiles() { - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); //confirmed languages till now assertThat(service.getAvailableLocales()) .contains(Locale.GERMAN, Locale.ENGLISH); @@ -262,7 +293,7 @@ public void testGetAvailableLocalesDetectsExistingBundleFiles() { @Test public void testGetAvailableLocalesDoesNotContainAbsentLocales() { - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); assertThat(service.getAvailableLocales()) .doesNotContain(Locale.FRENCH, Locale.JAPANESE, Locale.forLanguageTag("zh")); @@ -279,7 +310,7 @@ public void testGetAvailableLocalesFromDirectory(@TempDir Path bundleRoot) throw ClassLoader original = Thread.currentThread().getContextClassLoader(); try (URLClassLoader loader = new URLClassLoader(new URL[] { bundleRoot.toUri().toURL() }, ClassLoader.getPlatformClassLoader())) { Thread.currentThread().setContextClassLoader(loader); - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); assertThat(service.getAvailableLocales()) .containsExactlyInAnyOrder(Locale.ENGLISH, Locale.FRENCH); } finally { @@ -299,7 +330,7 @@ public void testGetAvailableLocalesFromJar(@TempDir Path jarRoot) throws Excepti ClassLoader original = Thread.currentThread().getContextClassLoader(); try (URLClassLoader loader = new URLClassLoader(new URL[] { jarFile.toUri().toURL() }, ClassLoader.getPlatformClassLoader())) { Thread.currentThread().setContextClassLoader(loader); - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); assertThat(service.getAvailableLocales()) .containsExactlyInAnyOrder(Locale.ENGLISH, Locale.FRENCH); } finally { @@ -307,6 +338,15 @@ public void testGetAvailableLocalesFromJar(@TempDir Path jarRoot) throws Excepti } } + private static Properties loadUserIdeProperties(Path userProperties) throws IOException { + + Properties properties = new Properties(); + try (InputStream inputStream = Files.newInputStream(userProperties)) { + properties.load(inputStream); + } + return properties; + } + private Properties loadBundleProperties(Locale locale) throws IOException { String resourceName = locale.equals(Locale.ENGLISH) From 6e62118c4d971ad656c583e39c4ba4e8396ee19e Mon Sep 17 00:00:00 2001 From: devonfw-core Date: Fri, 25 Sep 2026 10:00:52 +0000 Subject: [PATCH 28/66] set release version to 2026.09.002 --- .mvn/maven.config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mvn/maven.config b/.mvn/maven.config index 19698727..3ed2f725 100644 --- a/.mvn/maven.config +++ b/.mvn/maven.config @@ -1 +1 @@ --Drevision=2026.09.002-SNAPSHOT +-Drevision=2026.09.002 From f97a15d8bfcf4c2e29c0d881383755e2453617bb Mon Sep 17 00:00:00 2001 From: devonfw-core Date: Fri, 25 Sep 2026 10:06:02 +0000 Subject: [PATCH 29/66] set next version to 2026.09.003-SNAPSHOT --- .mvn/maven.config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mvn/maven.config b/.mvn/maven.config index 3ed2f725..8b440176 100644 --- a/.mvn/maven.config +++ b/.mvn/maven.config @@ -1 +1 @@ --Drevision=2026.09.002 +-Drevision=2026.09.003-SNAPSHOT From 90e39f6de74428f359eaafcf02fffb0656ecd5cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Hohwiller?= Date: Fri, 25 Sep 2026 14:30:26 +0200 Subject: [PATCH 30/66] Update Maven revision version to 2026.10.001-SNAPSHOT --- .mvn/maven.config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mvn/maven.config b/.mvn/maven.config index 8b440176..fd19e62f 100644 --- a/.mvn/maven.config +++ b/.mvn/maven.config @@ -1 +1 @@ --Drevision=2026.09.003-SNAPSHOT +-Drevision=2026.10.001-SNAPSHOT From f2f5b6755513f25bd31296d631bb36a3b9083d4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Hohwiller?= Date: Fri, 25 Sep 2026 14:31:12 +0200 Subject: [PATCH 31/66] Update CHANGELOG for version 2026.10.001 Added release notes for version 2026.10.001. --- CHANGELOG.adoc | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 985d114a..8c3c9b94 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -2,6 +2,13 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDEasy]. +== 2026.10.001 + +Release with new features and bugfixes: + + +The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/51?closed=1[milestone 2026.10.001]. + == 2026.09.002 Release with new features and bugfixes: From b97209681c77b3339266fe274ee4bc85fbc1373d Mon Sep 17 00:00:00 2001 From: krystynaShatkovska Date: Fri, 25 Sep 2026 16:05:02 +0200 Subject: [PATCH 32/66] #2381: node npm bash isolation poc (#2395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jörg Hohwiller --- CHANGELOG.adoc | 1 + .../devonfw/tools/ide/common/SystemPath.java | 36 +++- .../com/devonfw/tools/ide/tool/node/Node.java | 29 --- .../com/devonfw/tools/ide/tool/npm/Npm.java | 167 ++++++++++++++++-- cli/src/main/package/functions | 16 ++ cli/src/main/package/functions.ps1 | 42 +++++ .../commandlet/EnvironmentCommandletTest.java | 6 +- .../tools/ide/common/SystemPathTest.java | 23 +++ .../tools/ide/tool/corepack/CorepackTest.java | 1 - .../devonfw/tools/ide/tool/npm/NpmTest.java | 93 +++++++++- .../devonfw/tools/ide/tool/yarn/YarnTest.java | 1 - .../build/repository/npm/npm/default/bin/npm | 6 + .../build/repository/npm/npm/default/bin/npx | 2 + .../cdk/_ide/urls/npm/npm/9.9.2/urls | 1 + .../cdk/repository/npm/npm/default/bin/npm | 6 + .../cdk/repository/npm/npm/default/bin/npx | 2 + .../corepack/_ide/urls/npm/npm/9.9.2/urls | 1 + .../repository/npm/npm/default/bin/npm | 21 +++ .../repository/npm/npm/default/bin/npx | 2 + .../nest/_ide/urls/npm/npm/9.9.2/urls | 1 + .../nest/repository/npm/npm/default/bin/npm | 6 + .../nest/repository/npm/npm/default/bin/npx | 2 + .../ng/_ide/urls/npm/npm/9.9.2/urls | 1 + .../ng/repository/npm/npm/default/bin/npm | 6 + .../ng/repository/npm/npm/default/bin/npx | 2 + .../npm/_ide/urls/npm/npm/9.9.2/urls | 1 + .../npm/repository/npm/npm/default/bin/npm | 6 + .../npm/repository/npm/npm/default/bin/npx | 2 + .../task/_ide/urls/npm/npm/9.9.2/urls | 1 + .../task/repository/npm/npm/default/bin/npm | 6 + .../task/repository/npm/npm/default/bin/npx | 2 + .../yarn/_ide/urls/npm/npm/9.9.2/urls | 1 + .../yarn/repository/npm/npm/default/bin/npm | 21 +++ .../yarn/repository/npm/npm/default/bin/npx | 2 + 34 files changed, 459 insertions(+), 57 deletions(-) create mode 100755 cli/src/test/resources/ide-projects/build/repository/npm/npm/default/bin/npm create mode 100755 cli/src/test/resources/ide-projects/build/repository/npm/npm/default/bin/npx create mode 100644 cli/src/test/resources/ide-projects/cdk/_ide/urls/npm/npm/9.9.2/urls create mode 100755 cli/src/test/resources/ide-projects/cdk/repository/npm/npm/default/bin/npm create mode 100755 cli/src/test/resources/ide-projects/cdk/repository/npm/npm/default/bin/npx create mode 100644 cli/src/test/resources/ide-projects/corepack/_ide/urls/npm/npm/9.9.2/urls create mode 100755 cli/src/test/resources/ide-projects/corepack/repository/npm/npm/default/bin/npm create mode 100755 cli/src/test/resources/ide-projects/corepack/repository/npm/npm/default/bin/npx create mode 100644 cli/src/test/resources/ide-projects/nest/_ide/urls/npm/npm/9.9.2/urls create mode 100755 cli/src/test/resources/ide-projects/nest/repository/npm/npm/default/bin/npm create mode 100755 cli/src/test/resources/ide-projects/nest/repository/npm/npm/default/bin/npx create mode 100644 cli/src/test/resources/ide-projects/ng/_ide/urls/npm/npm/9.9.2/urls create mode 100755 cli/src/test/resources/ide-projects/ng/repository/npm/npm/default/bin/npm create mode 100755 cli/src/test/resources/ide-projects/ng/repository/npm/npm/default/bin/npx create mode 100644 cli/src/test/resources/ide-projects/npm/_ide/urls/npm/npm/9.9.2/urls create mode 100755 cli/src/test/resources/ide-projects/npm/repository/npm/npm/default/bin/npm create mode 100755 cli/src/test/resources/ide-projects/npm/repository/npm/npm/default/bin/npx create mode 100644 cli/src/test/resources/ide-projects/task/_ide/urls/npm/npm/9.9.2/urls create mode 100755 cli/src/test/resources/ide-projects/task/repository/npm/npm/default/bin/npm create mode 100755 cli/src/test/resources/ide-projects/task/repository/npm/npm/default/bin/npx create mode 100644 cli/src/test/resources/ide-projects/yarn/_ide/urls/npm/npm/9.9.2/urls create mode 100755 cli/src/test/resources/ide-projects/yarn/repository/npm/npm/default/bin/npm create mode 100755 cli/src/test/resources/ide-projects/yarn/repository/npm/npm/default/bin/npx diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 8c3c9b94..9201bf38 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -6,6 +6,7 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDE Release with new features and bugfixes: +* https://github.com/devonfw/IDEasy/issues/2381[#2381]: Make node and npm pristine versioned tools with per-project npm global prefix The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/51?closed=1[milestone 2026.10.001]. diff --git a/cli/src/main/java/com/devonfw/tools/ide/common/SystemPath.java b/cli/src/main/java/com/devonfw/tools/ide/common/SystemPath.java index d3215a5c..b4ff2e97 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/common/SystemPath.java +++ b/cli/src/main/java/com/devonfw/tools/ide/common/SystemPath.java @@ -50,6 +50,16 @@ public class SystemPath { private final IdeContext context; + /** + * Tools whose binaries are also bundled inside another (runtime) tool's install folder and would therefore be shadowed by it on the PATH. + *

+ * {@code node} ships its own {@code npm}/{@code npx}/{@code corepack} inside its (flat, no {@code bin/}) install folder, so without an explicit order the + * pristine, independently versioned {@code npm} install and the node-bundled one would compete for the same binary names in an arbitrary order + * ({@code tool2pathMap} is a {@link HashMap}). Ordering these tools first guarantees the pristine installation deterministically wins. If another tool ever + * becomes independently versioned the same way, it is added here. + */ + private static final List PATH_PRECEDENCE_TOOLS = List.of("npm"); + private static final List EXTENSION_PRIORITY = List.of(".exe", ".cmd", ".bat", ".msi", ".ps1", ""); /** @@ -205,6 +215,28 @@ private void collectToolPath(Path softwarePath) { } } + /** + * @return the tool bin {@link Path}s in the order in which they must be searched and placed on the PATH. The {@link #PATH_PRECEDENCE_TOOLS} are placed + * first so that their executables deterministically take precedence over the copies bundled with the runtime that also ships them (e.g. the pristine + * {@code npm} over the {@code npm}/{@code npx} bundled with {@code node}); the remaining tools are returned in their map order. + */ + private List getToolPathsInResolutionOrder() { + + List orderedPaths = new ArrayList<>(this.tool2pathMap.size()); + for (String tool : PATH_PRECEDENCE_TOOLS) { + Path toolPath = this.tool2pathMap.get(tool); + if (toolPath != null) { + orderedPaths.add(toolPath); + } + } + for (Map.Entry entry : this.tool2pathMap.entrySet()) { + if (!PATH_PRECEDENCE_TOOLS.contains(entry.getKey())) { + orderedPaths.add(entry.getValue()); + } + } + return orderedPaths; + } + private static String getTool(Path path, Path ideRoot) { if (ideRoot == null) { @@ -290,7 +322,7 @@ public Path findBinary(Path toolPath, Predicate filter) { return binaryPath; } } - for (Path path : this.tool2pathMap.values()) { + for (Path path : getToolPathsInResolutionOrder()) { Path binaryPath = findBinaryInOrder(path, fileName); if (binaryPath != null && filter.test(binaryPath)) { return binaryPath; @@ -352,7 +384,7 @@ public String toString(WindowsPathSyntax pathSyntax) { for (Path path : this.extraPathEntries) { appendPath(path, sb, separator, pathSyntax); } - for (Path path : this.tool2pathMap.values()) { + for (Path path : getToolPathsInResolutionOrder()) { appendPath(path, sb, separator, pathSyntax); } for (Path path : this.paths) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/node/Node.java b/cli/src/main/java/com/devonfw/tools/ide/tool/node/Node.java index c478896c..b039f4c7 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/node/Node.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/node/Node.java @@ -7,14 +7,8 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.nls.NlsBundle; -import com.devonfw.tools.ide.process.ProcessResult; import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.PackageManagerRequest; -import com.devonfw.tools.ide.tool.ToolCommandlet; -import com.devonfw.tools.ide.tool.ToolInstallRequest; -import com.devonfw.tools.ide.tool.npm.Npm; /** * {@link ToolCommandlet} for node. @@ -33,32 +27,9 @@ public Node(IdeContext context) { super(context, "node", Set.of(Tag.JAVA_SCRIPT, Tag.RUNTIME)); } - @Override - protected void postInstallOnNewInstallation(ToolInstallRequest request) { - - super.postInstallOnNewInstallation(request); - // this code is slightly dangerous: npm has a dependency to node, while here in node we call npm causing a cyclic dependency - // the problem is that node package already comes with npm so we already have to configure npm properly here - // inside npm we have to guarantee that we will not trigger an installation (again) causing an infinity loop - // we would love to get this clean but node and npm are already flawed forcing us to do such hacks... - Npm npm = this.context.getCommandletManager().getCommandlet(Npm.class); - PackageManagerRequest packageManagerRequest = new PackageManagerRequest("config", this.tool).addArg("config").addArg("set").addArg("prefix") - .addArg(getToolPath().toString()); - ProcessResult result = npm.runPackageManager(packageManagerRequest, true); - if (result.isSuccessful()) { - IdeLogLevel.SUCCESS.log(LOG, "Setting npm config prefix to: {} was successful", getToolPath()); - } - } - @Override public void printHelp(NlsBundle bundle) { LOG.info("For a list of supported options and arguments, use \"node --help\""); } - - @Override - protected boolean isIgnoreSoftwareRepo() { - - return true; - } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/npm/Npm.java b/cli/src/main/java/com/devonfw/tools/ide/tool/npm/Npm.java index ab2aa92d..72ec8f8f 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/npm/Npm.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/npm/Npm.java @@ -4,19 +4,46 @@ import java.nio.file.Path; import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.io.FileAccess; import com.devonfw.tools.ide.process.EnvironmentContext; +import com.devonfw.tools.ide.tool.LocalToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallation; -import com.devonfw.tools.ide.version.VersionIdentifier; /** - * {@link NpmBasedCommandlet} for npm. + * {@link LocalToolCommandlet} for npm. + *

+ * npm is installed as a pristine, versioned installation in the software repository (same model as the other tools) and is + * linked into each project's {@code software} folder. Global npm packages are installed into a per-project directory + * (see {@link #NPM_GLOBAL_FOLDER}) so that projects do not interfere with each other (see issue #352 and issue #2381). */ -public class Npm extends NpmBasedCommandlet { +public class Npm extends LocalToolCommandlet { + + private static final Logger LOG = LoggerFactory.getLogger(Npm.class); private static final String NPM_HOME_FOLDER = "npm"; + /** The npm CLI entry point inside a flat npm installation ({@code /bin/npm-cli.js}). */ + static final String NPM_CLI_JS = "npm-cli.js"; + + /** The npx CLI entry point inside a flat npm installation ({@code /bin/npx-cli.js}). */ + static final String NPX_CLI_JS = "npx-cli.js"; + + /** The command name of the {@link com.devonfw.tools.ide.tool.node.Node node.js} runtime to launch. */ + static final String NODE = "node"; + + /** File name of the {@link #findBuildDescriptor(Path) build descriptor} of an npm project. */ + private static final String PACKAGE_JSON = "package.json"; + + /** The folder name for the per-project global npm packages inside {@link IdeContext#getIdeHome() IDE_HOME}. */ + public static final String NPM_GLOBAL_FOLDER = ".npm-global"; + /** * The constructor. * @@ -27,34 +54,35 @@ public Npm(IdeContext context) { super(context, "npm", Set.of(Tag.JAVA_SCRIPT, Tag.BUILD)); } - @Override - protected boolean canBeUninstalled() { - return false; - } - - @Override - protected VersionIdentifier computeInstalledPackageVersion() { - if (hasNodeBinary("npm")) { - return VersionIdentifier.of(this.context.newProcess().runAndGetSingleOutput("npm", "--version")); - } - return null; - } - @Override public String getToolHelpArguments() { return "help"; } + /** + * Detects an npm project by its {@code package.json} build descriptor so that the {@code build} commandlet (and the + * {@link com.devonfw.tools.ide.commandlet.BuildCommandlet BuildCommandlet}) can dispatch it to npm. npm is a standalone tool + * (not a child of {@link com.devonfw.tools.ide.tool.node.Node node}) now, so it must declare its build descriptor itself. + * + * @param directory the {@link Path} to the build directory. + * @return the {@code package.json} {@link Path} if it exists, or {@code null} otherwise. + */ @Override - protected boolean isSkipInstallation() { - return true; + public Path findBuildDescriptor(Path directory) { + + Path buildDescriptor = directory.resolve(PACKAGE_JSON); + if (Files.exists(buildDescriptor)) { + return buildDescriptor; + } + return super.findBuildDescriptor(directory); } /** * @return the {@link Path} to the npm user configuration file, creates the folder and configuration file if it was not existing. */ public Path getOrCreateNpmConfigUserConfig() { + Path confPath = this.context.getConfPath().resolve(NPM_HOME_FOLDER); Path npmConfigFile = confPath.resolve(".npmrc"); if (!Files.isDirectory(confPath)) { @@ -66,7 +94,108 @@ public Path getOrCreateNpmConfigUserConfig() { @Override public void setEnvironment(EnvironmentContext environmentContext, ToolInstallation toolInstallation, boolean additionalInstallation) { + super.setEnvironment(environmentContext, toolInstallation, additionalInstallation); - environmentContext.withEnvVar("npm_config_prefix", this.context.getSoftwarePath().resolve("node").toString()); + // Global npm packages must not be installed into the shared node installation (issue #352) - they go into a per-project + // directory instead so that projects do not interfere with each other. Outside of a project we do not pin the prefix so + // that the system npm (if the tool is not installed by IDEasy) keeps its own behavior. + Path ideHome = this.context.getIdeHome(); + if (ideHome != null) { + Path npmGlobalPath = ideHome.resolve(NPM_GLOBAL_FOLDER); + environmentContext.withEnvVar("npm_config_prefix", npmGlobalPath.toString()); + environmentContext.withPathEntry(npmGlobalPath.resolve(IdeContext.FOLDER_BIN)); + } + } + + /** + * Repairs the npm launcher shims ({@code npm}/{@code npx}) so that they resolve to this pristine installation instead of the npm that is bundled with + * node. + *

+ * The npm registry tarball extracts a flat layout ({@code bin/npm-cli.js}) but ships the launcher shims in the layout npm uses when it is bundled inside + * a node distribution: {@code bin/npm.cmd}, {@code bin/npx.cmd}, {@code bin/npm.ps1}, {@code bin/npx.ps1}, {@code bin/npm} and {@code bin/npx} all point + * at a non-existent {@code node_modules/npm/bin/npm-cli.js} (or a sibling {@code node.exe}). Consequently, on Windows the {@code npm}/{@code npx} shims + * fail with {@code MODULE_NOT_FOUND}, and on Linux they silently run the npm that is bundled with the node distribution rather than this pristine npm. + * Since the npm bin folder is first on the PATH (see + * {@link com.devonfw.tools.ide.common.SystemPath#getToolPathsInResolutionOrder()}), the broken shims would otherwise shadow the correct tool for every + * invocation of {@code npm}/{@code npx}. + *

+ * This hook rewrites the shims to launch this installation's own CLI entry points ({@code bin/npm-cli.js}/{@code bin/npx-cli.js}) with the {@code node} + * runtime that is already on the PATH. This is a no-op for installations that do not contain a flat {@code bin/npm-cli.js} (e.g. a node-bundled npm or the + * pre-seeded test fixtures) so their shims are left untouched. + * + * @param extractedDir the {@link Path} to the folder with the unpacked npm tool (the package root, containing {@code bin/}). + */ + @Override + protected void postExtract(Path extractedDir) { + + super.postExtract(extractedDir); + Path bin = extractedDir.resolve(IdeContext.FOLDER_BIN); + if (!Files.isRegularFile(bin.resolve(NPM_CLI_JS))) { + return; + } + FileAccess fileAccess = this.context.getFileAccess(); + boolean windows = this.context.getSystemInfo().isWindows(); + if (windows) { + // The .cmd/.ps1 shims are only ever executed on Windows; on other platforms they are not run and thus left untouched. + repairShim(fileAccess, bin.resolve("npm.cmd"), cmdShim(NPM_CLI_JS)); + repairShim(fileAccess, bin.resolve("npx.cmd"), cmdShim(NPX_CLI_JS)); + repairShim(fileAccess, bin.resolve("npm.ps1"), psShim(NPM_CLI_JS)); + repairShim(fileAccess, bin.resolve("npx.ps1"), psShim(NPX_CLI_JS)); + } else { + // The POSIX shims are only ever executed on non-Windows systems; the .cmd/.ps1 shims are not run there and are left untouched. + repairShim(fileAccess, bin.resolve("npm"), posixShim(NPM_CLI_JS)); + repairShim(fileAccess, bin.resolve("npx"), posixShim(NPX_CLI_JS)); + } + LOG.debug("Repaired the npm launcher shims of the pristine npm installation at {} to use the flat layout.", bin); + } + + /** + * Overwrites the given launcher shim with the provided flat-layout content and, on non-Windows systems, marks it executable so that the POSIX shims + * {@code bin/npm}/{@code bin/npx} are runnable. + * + * @param fileAccess the {@link FileAccess} to use. + * @param shim the {@link Path} of the shim to repair. + * @param content the new flat-layout content for the shim. + */ + private void repairShim(FileAccess fileAccess, Path shim, String content) { + + fileAccess.writeFileContent(content, shim, false); + // On non-Windows, make the POSIX shims (bin/npm, bin/npx) executable so they can be launched from the PATH. + // The .cmd/.ps1 shims are not launched as POSIX executables and do not need the execute bit. + if (!this.context.getSystemInfo().isWindows() && !shim.getFileName().toString().contains(".")) { + fileAccess.makeExecutable(shim); + } + } + + /** + * @param cliJs the {@link #NPM_CLI_JS npm CLI entry point} (or {@link #NPX_CLI_JS} for npx) to launch. + * @return the {@code .cmd} (Windows) shim content for the flat npm layout. + */ + private static String cmdShim(String cliJs) { + + return "@ECHO OFF\r\n" // + + "SET \"NODE_CMD=" + NODE + "\"\r\n" // + + "\"%NODE_CMD%\" \"%~dp0" + cliJs + "\" %*\r\n"; + } + + /** + * @param cliJs the {@link #NPM_CLI_JS npm CLI entry point} (or {@link #NPX_CLI_JS} for npx) to launch. + * @return the {@code .ps1} (PowerShell) shim content for the flat npm layout. + */ + private static String psShim(String cliJs) { + + return "$node = \"" + NODE + "\"\r\n" // + + "& $node (Join-Path $PSScriptRoot \"" + cliJs + "\") @args\r\n"; + } + + /** + * @param cliJs the {@link #NPM_CLI_JS npm CLI entry point} (or {@link #NPX_CLI_JS} for npx) to launch. + * @return the POSIX shim content for the flat npm layout. + */ + private static String posixShim(String cliJs) { + + return "#!/usr/bin/env bash\r\n" // + + "basedir=\"$(dirname \"$0\")\"\r\n" // + + "exec node \"$basedir/" + cliJs + "\" \"$@\"\r\n"; } } diff --git a/cli/src/main/package/functions b/cli/src/main/package/functions index 3fa7b8a1..258cd71e 100644 --- a/cli/src/main/package/functions +++ b/cli/src/main/package/functions @@ -239,6 +239,22 @@ function claude() { fi } +function node() { + if [ -n "${IDE_HOME}" ]; then + ide node "$@" + else + command node "$@" + fi +} + +function npm() { + if [ -n "${IDE_HOME}" ]; then + ide npm "$@" + else + command npm "$@" + fi +} + _ide_create_project() { local found_create=false diff --git a/cli/src/main/package/functions.ps1 b/cli/src/main/package/functions.ps1 index 9050a1cd..3bd5666f 100644 --- a/cli/src/main/package/functions.ps1 +++ b/cli/src/main/package/functions.ps1 @@ -312,6 +312,48 @@ function claude { & $claudeCommand.Source @args } +function node { + if (-not [string]::IsNullOrEmpty($env:IDE_HOME)) { + ide node @args + return + } + + # "node" is now a PowerShell function, so explicitly search for + # the external command to avoid recursively calling this function. + $nodeCommand = Get-Command node ` + -CommandType Application, ExternalScript ` + -ErrorAction SilentlyContinue | + Select-Object -First 1 + + if ($null -eq $nodeCommand) { + Write-Error "The command 'node' could not be found." + return + } + + & $nodeCommand.Source @args +} + +function npm { + if (-not [string]::IsNullOrEmpty($env:IDE_HOME)) { + ide npm @args + return + } + + # "npm" is now a PowerShell function, so explicitly search for + # the external command to avoid recursively calling this function. + $npmCommand = Get-Command npm ` + -CommandType Application, ExternalScript ` + -ErrorAction SilentlyContinue | + Select-Object -First 1 + + if ($null -eq $npmCommand) { + Write-Error "The command 'npm' could not be found." + return + } + + & $npmCommand.Source @args +} + function _ide_completion { $registerCommand = Get-Command Register-ArgumentCompleter -ErrorAction SilentlyContinue diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandletTest.java index 6fc49aa5..cf409501 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandletTest.java @@ -10,6 +10,7 @@ import com.devonfw.tools.ide.log.IdeLogEntry; import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.os.SystemInfoMock; +import com.devonfw.tools.ide.tool.npm.Npm; /** * Test of {@link EnvironmentCommandlet}. @@ -126,6 +127,7 @@ void testRunInfoLogging() { IdeLogEntry.ofProcessable("export MVN_HOME=\"" + softwarePath.resolve("mvn") + "\""), IdeLogEntry.ofProcessable("MVN_VERSION=\"3.9.1\""), // IdeLogEntry.ofProcessable("export NPM_CONFIG_USERCONFIG=\"" + normalize(context.getNpmConfigUserConfig()) + "\""), // + IdeLogEntry.ofProcessable("export NPM_HOME=\"" + context.getSoftwarePath() + FileSystems.getDefault().getSeparator() + "npm\""), // new IdeLogEntry(IdeLogLevel.PROCESSABLE, "export PATH=", true), // IdeLogEntry.ofProcessable("SOME=\"some-${UNDEFINED}\""), // IdeLogEntry.ofProcessable("TEST_ARGS1=\" user1 settings1 workspace1 conf1\""), // @@ -144,7 +146,9 @@ void testRunInfoLogging() { IdeLogEntry.ofProcessable("TEST_ARGSd=\" user1 settings1 workspace1 conf1 userd workspaced\""), // IdeLogEntry.ofProcessable("export UV_CACHE_DIR=\"" + normalize(context.getIdeRoot().resolve("_ide/software/uv")) + "\""), // IdeLogEntry.ofProcessable("WORKSPACE=\"foo-test\""), // - IdeLogEntry.ofProcessable("WORKSPACE_PATH=\"" + normalize(context.getWorkspacePath()) + "\"") // + IdeLogEntry.ofProcessable("WORKSPACE_PATH=\"" + normalize(context.getWorkspacePath()) + "\""), // + IdeLogEntry.ofProcessable( + "export npm_config_prefix=\"" + context.getIdeHome() + FileSystems.getDefault().getSeparator() + Npm.NPM_GLOBAL_FOLDER + "\"") // ); } diff --git a/cli/src/test/java/com/devonfw/tools/ide/common/SystemPathTest.java b/cli/src/test/java/com/devonfw/tools/ide/common/SystemPathTest.java index 2fff021f..98b008fb 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/common/SystemPathTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/common/SystemPathTest.java @@ -145,6 +145,29 @@ void testFindBinaryFindsNothingWithoutFilter() { assertThat(result).isEqualTo(test); } + @Test + void testPrecedenceToolBinPrecedesBundlingRuntimeBinForBothLookupAndPathString() throws IOException { + // arrange - node is a flat install (no bin/), so node's own bundled npm shadows the pristine npm unless npm is ordered first + // copyForMutation=true: this test creates files, so it must work on a scratch copy (in target/), never the committed fixtures + IdeTestContext context = newContext("find-binary", "project/workspaces", true); + Path npmTool = context.getSoftwarePath().resolve("npm"); + Files.createDirectories(npmTool.resolve("bin")); + Files.writeString(npmTool.resolve("bin/npm"), "pristine-npm"); + Path nodeTool = context.getSoftwarePath().resolve("node"); + Files.createDirectories(nodeTool); + Files.writeString(nodeTool.resolve("npm"), "node-bundled-npm"); + SystemPath systemPath = new SystemPath(context, "", context.getIdeRoot(), context.getSoftwarePath(), ';', new ArrayList<>()); + Path pristine = npmTool.resolve("bin").resolve("npm"); + Path bundled = nodeTool.resolve("npm"); + + // act & assert - findBinary resolves the pristine npm, not the node-bundled one + assertThat(systemPath.findBinary(Path.of("npm"))).isEqualTo(pristine).isNotEqualTo(bundled); + // act & assert - the pristine npm bin is also placed before the node dir in the PATH string + String pathString = systemPath.toString(); + assertThat(pathString.indexOf(npmTool.resolve("bin").toString())) + .isNotNegative().isLessThan(pathString.indexOf(nodeTool.toString())); + } + @Test void testFindBinaryFindsBinaryInExtraPathEntries() throws IOException { // arrange diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/corepack/CorepackTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/corepack/CorepackTest.java index fd7546f1..7b43aab8 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/corepack/CorepackTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/corepack/CorepackTest.java @@ -88,7 +88,6 @@ void testCorepackRun(WireMockRuntimeInfo wireMockRuntimeInfo) { private void checkInstallation(IdeTestContext context) { assertThat(context).logAtInfo().hasMessageContaining("npm install -gf corepack@0.34.0"); - assertThat(context).logAtSuccess().hasMessageContaining("Setting npm config prefix to: " + context.getSoftwarePath().resolve("node") + " was successful"); assertThat(context).logAtSuccess().hasMessageContaining("Successfully installed corepack in version 0.34.0"); } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/npm/NpmTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/npm/NpmTest.java index b5bdd75f..84ba1c0e 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/npm/NpmTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/npm/NpmTest.java @@ -1,9 +1,18 @@ package com.devonfw.tools.ide.tool.npm; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + import org.junit.jupiter.api.Test; import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.io.FileAccess; +import com.devonfw.tools.ide.process.EnvironmentContext; +import com.devonfw.tools.ide.tool.ToolInstallation; +import com.devonfw.tools.ide.tool.claude.RecordingEnvironmentContext; +import com.devonfw.tools.ide.version.VersionIdentifier; import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; @@ -77,16 +86,92 @@ void testNpmUninstall(WireMockRuntimeInfo wireMockRuntimeInfo) { // assert II assertThat(context).logAtInfo().hasNoMessageContaining("npm uninstall -g npm"); - assertThat(context).logAtInfo().hasMessageContaining("IDEasy does not support uninstalling the tool npm since this will break your installation.\n" - + "If you really want to uninstall it, please uninstall its parent tool via:\n" - + "ide uninstall node"); assertThat(context).logAtSuccess().hasMessage("Successfully uninstalled npm"); } + /** + * Tests that {@link Npm#setEnvironment(EnvironmentContext, ToolInstallation, boolean)} points the npm global prefix at a per-project + * {@code .npm-global} folder inside the IDE home so that projects do not interfere with each other (see + * issue #352 and issue #2381). + */ + @Test + void testSetEnvironmentPointsGlobalPrefixToPerProjectNpmGlobal() { + + // arrange + IdeTestContext context = newContext(PROJECT_NPM, (String) null, false); + Npm commandlet = new Npm(context); + Path dummy = context.getSoftwarePath().resolve("npm"); + ToolInstallation installation = new ToolInstallation(dummy, dummy, dummy, VersionIdentifier.of("9.9.2"), false); + RecordingEnvironmentContext environmentContext = new RecordingEnvironmentContext(); + + // act + commandlet.setEnvironment(environmentContext, installation, false); + + // assert + Path npmGlobalPath = context.getIdeHome().resolve(Npm.NPM_GLOBAL_FOLDER); + assertThat(environmentContext.set).containsEntry("npm_config_prefix", npmGlobalPath.toString()); + } + + /** + * Tests that {@link Npm#postExtract(Path)} rewrites the npm launcher shims (that the npm registry tarball ships in the node-bundled layout) to the flat + * layout, so that {@code npm}/{@code npx} resolve to this pristine installation instead of the npm bundled with node (see issue #2381). + */ + @Test + void testPostExtractRepairsFlatLayoutShims() { + + // arrange - a flat npm installation (as extracted from the npm registry tarball) with the broken node-bundled-layout shims + IdeTestContext context = newContext(PROJECT_NPM, (String) null, false); + Npm commandlet = new Npm(context); + FileAccess fileAccess = context.getFileAccess(); + Path extractedDir = null; + try { + extractedDir = Files.createTempDirectory("npm-shim-repair"); + Path bin = extractedDir.resolve("bin"); + fileAccess.mkdirs(bin); + // flat-layout CLI entry points (these are the ones the shims should launch) + fileAccess.touch(bin.resolve("npm-cli.js")); + fileAccess.touch(bin.resolve("npx-cli.js")); + // the node-bundled-layout shims that npm's registry tarball ships (broken - they point at node_modules/npm/bin/npm-cli.js) + String bundledShim = "node node_modules\\npm\\bin\\npm-cli.js"; + for (String shim : new String[] { "npm.cmd", "npx.cmd", "npm.ps1", "npx.ps1", "npm", "npx" }) { + fileAccess.writeFileContent(bundledShim, bin.resolve(shim), false); + } + + // act + commandlet.postExtract(extractedDir); + + // assert - only the shims of the current platform were repaired and now launch this installation's own CLI entry + // points instead of the broken node-bundled layout (node_modules\npm\bin\npm-cli.js) + boolean windows = context.getSystemInfo().isWindows(); + if (windows) { + String npmCmd = fileAccess.readFileContent(bin.resolve("npm.cmd")); + org.assertj.core.api.Assertions.assertThat(npmCmd).contains("npm-cli.js").doesNotContain("node_modules"); + String npxCmd = fileAccess.readFileContent(bin.resolve("npx.cmd")); + org.assertj.core.api.Assertions.assertThat(npxCmd).contains("npx-cli.js").doesNotContain("node_modules"); + String npmPs1 = fileAccess.readFileContent(bin.resolve("npm.ps1")); + org.assertj.core.api.Assertions.assertThat(npmPs1).contains("npm-cli.js").doesNotContain("node_modules"); + } else { + String npmPosix = fileAccess.readFileContent(bin.resolve("npm")); + org.assertj.core.api.Assertions.assertThat(npmPosix).contains("npm-cli.js").doesNotContain("node_modules"); + String npxPosix = fileAccess.readFileContent(bin.resolve("npx")); + org.assertj.core.api.Assertions.assertThat(npxPosix).contains("npx-cli.js").doesNotContain("node_modules"); + } + // the CLI entry points themselves are left untouched + org.assertj.core.api.Assertions.assertThat(Files.isRegularFile(bin.resolve("npm-cli.js"))).isTrue(); + } catch (IOException e) { + throw new RuntimeException(e); + } finally { + if (extractedDir != null) { + fileAccess.delete(extractedDir); + } + } + } + private void checkInstallation(IdeTestContext context) { assertThat(context).logAtSuccess().hasMessageContaining("Successfully installed npm in version 9.9.2"); - assertThat(context).logAtSuccess().hasMessageContaining("Setting npm config prefix to: " + context.getSoftwarePath().resolve("node") + " was successful"); } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/yarn/YarnTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/yarn/YarnTest.java index 0750c3b1..345d10b4 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/yarn/YarnTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/yarn/YarnTest.java @@ -105,7 +105,6 @@ void testYarnRun(WireMockRuntimeInfo wireMockRuntimeInfo) { private void checkInstallation(IdeTestContext context) { assertThat(context).logAtInfo().hasMessage("npm install -gf yarn@2.4.3"); - assertThat(context).logAtSuccess().hasMessageContaining("Setting npm config prefix to: " + context.getSoftwarePath().resolve("node") + " was successful"); assertThat(context).logAtSuccess().hasMessageContaining("Successfully installed yarn in version 2.4.3"); } } diff --git a/cli/src/test/resources/ide-projects/build/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/build/repository/npm/npm/default/bin/npm new file mode 100755 index 00000000..f5a0cfa6 --- /dev/null +++ b/cli/src/test/resources/ide-projects/build/repository/npm/npm/default/bin/npm @@ -0,0 +1,6 @@ +#!/bin/bash +if [ "${1}" == "--version" ]; then + echo "9.9.2" + exit +fi +echo "npm $*" diff --git a/cli/src/test/resources/ide-projects/build/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/build/repository/npm/npm/default/bin/npx new file mode 100755 index 00000000..0723c204 --- /dev/null +++ b/cli/src/test/resources/ide-projects/build/repository/npm/npm/default/bin/npx @@ -0,0 +1,2 @@ +#!/bin/bash +echo "npx $*" diff --git a/cli/src/test/resources/ide-projects/cdk/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/cdk/_ide/urls/npm/npm/9.9.2/urls new file mode 100644 index 00000000..290f63d9 --- /dev/null +++ b/cli/src/test/resources/ide-projects/cdk/_ide/urls/npm/npm/9.9.2/urls @@ -0,0 +1 @@ +${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz diff --git a/cli/src/test/resources/ide-projects/cdk/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/cdk/repository/npm/npm/default/bin/npm new file mode 100755 index 00000000..f5a0cfa6 --- /dev/null +++ b/cli/src/test/resources/ide-projects/cdk/repository/npm/npm/default/bin/npm @@ -0,0 +1,6 @@ +#!/bin/bash +if [ "${1}" == "--version" ]; then + echo "9.9.2" + exit +fi +echo "npm $*" diff --git a/cli/src/test/resources/ide-projects/cdk/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/cdk/repository/npm/npm/default/bin/npx new file mode 100755 index 00000000..0723c204 --- /dev/null +++ b/cli/src/test/resources/ide-projects/cdk/repository/npm/npm/default/bin/npx @@ -0,0 +1,2 @@ +#!/bin/bash +echo "npx $*" diff --git a/cli/src/test/resources/ide-projects/corepack/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/corepack/_ide/urls/npm/npm/9.9.2/urls new file mode 100644 index 00000000..290f63d9 --- /dev/null +++ b/cli/src/test/resources/ide-projects/corepack/_ide/urls/npm/npm/9.9.2/urls @@ -0,0 +1 @@ +${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz diff --git a/cli/src/test/resources/ide-projects/corepack/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/corepack/repository/npm/npm/default/bin/npm new file mode 100755 index 00000000..4eb4b307 --- /dev/null +++ b/cli/src/test/resources/ide-projects/corepack/repository/npm/npm/default/bin/npm @@ -0,0 +1,21 @@ +#!/bin/bash +if [ "$1" = "--version" ]; then + echo "9.9.2" + exit +fi +if [ "$1" = "list" ] && [ "$2" = "-g" ]; then + # Report a version only if the package binary is actually present in the per-project + # global prefix (pristine npm reports nothing for a package that is not installed yet). + if [ -n "$npm_config_prefix" ] && [ -e "$npm_config_prefix/bin/$3" ]; then + echo "-- $3@0.34.0" + fi + exit 1 +fi +if [ "$1" = "install" ]; then + # Pristine: global packages are installed into the per-project global prefix. + if [ -n "$npm_config_prefix" ]; then + mkdir -p "$npm_config_prefix/bin" + cp target/ide-projects/corepack/repository/corepack/corepack/default/corepack "$npm_config_prefix/bin/corepack" + fi +fi +echo "npm $*" diff --git a/cli/src/test/resources/ide-projects/corepack/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/corepack/repository/npm/npm/default/bin/npx new file mode 100755 index 00000000..0723c204 --- /dev/null +++ b/cli/src/test/resources/ide-projects/corepack/repository/npm/npm/default/bin/npx @@ -0,0 +1,2 @@ +#!/bin/bash +echo "npx $*" diff --git a/cli/src/test/resources/ide-projects/nest/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/nest/_ide/urls/npm/npm/9.9.2/urls new file mode 100644 index 00000000..290f63d9 --- /dev/null +++ b/cli/src/test/resources/ide-projects/nest/_ide/urls/npm/npm/9.9.2/urls @@ -0,0 +1 @@ +${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz diff --git a/cli/src/test/resources/ide-projects/nest/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/nest/repository/npm/npm/default/bin/npm new file mode 100755 index 00000000..f5a0cfa6 --- /dev/null +++ b/cli/src/test/resources/ide-projects/nest/repository/npm/npm/default/bin/npm @@ -0,0 +1,6 @@ +#!/bin/bash +if [ "${1}" == "--version" ]; then + echo "9.9.2" + exit +fi +echo "npm $*" diff --git a/cli/src/test/resources/ide-projects/nest/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/nest/repository/npm/npm/default/bin/npx new file mode 100755 index 00000000..0723c204 --- /dev/null +++ b/cli/src/test/resources/ide-projects/nest/repository/npm/npm/default/bin/npx @@ -0,0 +1,2 @@ +#!/bin/bash +echo "npx $*" diff --git a/cli/src/test/resources/ide-projects/ng/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/ng/_ide/urls/npm/npm/9.9.2/urls new file mode 100644 index 00000000..290f63d9 --- /dev/null +++ b/cli/src/test/resources/ide-projects/ng/_ide/urls/npm/npm/9.9.2/urls @@ -0,0 +1 @@ +${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz diff --git a/cli/src/test/resources/ide-projects/ng/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/ng/repository/npm/npm/default/bin/npm new file mode 100755 index 00000000..f5a0cfa6 --- /dev/null +++ b/cli/src/test/resources/ide-projects/ng/repository/npm/npm/default/bin/npm @@ -0,0 +1,6 @@ +#!/bin/bash +if [ "${1}" == "--version" ]; then + echo "9.9.2" + exit +fi +echo "npm $*" diff --git a/cli/src/test/resources/ide-projects/ng/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/ng/repository/npm/npm/default/bin/npx new file mode 100755 index 00000000..0723c204 --- /dev/null +++ b/cli/src/test/resources/ide-projects/ng/repository/npm/npm/default/bin/npx @@ -0,0 +1,2 @@ +#!/bin/bash +echo "npx $*" diff --git a/cli/src/test/resources/ide-projects/npm/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/npm/_ide/urls/npm/npm/9.9.2/urls new file mode 100644 index 00000000..290f63d9 --- /dev/null +++ b/cli/src/test/resources/ide-projects/npm/_ide/urls/npm/npm/9.9.2/urls @@ -0,0 +1 @@ +${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz diff --git a/cli/src/test/resources/ide-projects/npm/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/npm/repository/npm/npm/default/bin/npm new file mode 100755 index 00000000..f5a0cfa6 --- /dev/null +++ b/cli/src/test/resources/ide-projects/npm/repository/npm/npm/default/bin/npm @@ -0,0 +1,6 @@ +#!/bin/bash +if [ "${1}" == "--version" ]; then + echo "9.9.2" + exit +fi +echo "npm $*" diff --git a/cli/src/test/resources/ide-projects/npm/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/npm/repository/npm/npm/default/bin/npx new file mode 100755 index 00000000..0723c204 --- /dev/null +++ b/cli/src/test/resources/ide-projects/npm/repository/npm/npm/default/bin/npx @@ -0,0 +1,2 @@ +#!/bin/bash +echo "npx $*" diff --git a/cli/src/test/resources/ide-projects/task/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/task/_ide/urls/npm/npm/9.9.2/urls new file mode 100644 index 00000000..290f63d9 --- /dev/null +++ b/cli/src/test/resources/ide-projects/task/_ide/urls/npm/npm/9.9.2/urls @@ -0,0 +1 @@ +${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz diff --git a/cli/src/test/resources/ide-projects/task/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/task/repository/npm/npm/default/bin/npm new file mode 100755 index 00000000..f5a0cfa6 --- /dev/null +++ b/cli/src/test/resources/ide-projects/task/repository/npm/npm/default/bin/npm @@ -0,0 +1,6 @@ +#!/bin/bash +if [ "${1}" == "--version" ]; then + echo "9.9.2" + exit +fi +echo "npm $*" diff --git a/cli/src/test/resources/ide-projects/task/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/task/repository/npm/npm/default/bin/npx new file mode 100755 index 00000000..0723c204 --- /dev/null +++ b/cli/src/test/resources/ide-projects/task/repository/npm/npm/default/bin/npx @@ -0,0 +1,2 @@ +#!/bin/bash +echo "npx $*" diff --git a/cli/src/test/resources/ide-projects/yarn/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/yarn/_ide/urls/npm/npm/9.9.2/urls new file mode 100644 index 00000000..290f63d9 --- /dev/null +++ b/cli/src/test/resources/ide-projects/yarn/_ide/urls/npm/npm/9.9.2/urls @@ -0,0 +1 @@ +${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz diff --git a/cli/src/test/resources/ide-projects/yarn/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/yarn/repository/npm/npm/default/bin/npm new file mode 100755 index 00000000..64d54248 --- /dev/null +++ b/cli/src/test/resources/ide-projects/yarn/repository/npm/npm/default/bin/npm @@ -0,0 +1,21 @@ +#!/bin/bash +if [ "$1" = "--version" ]; then + echo "9.9.2" + exit +fi +if [ "$1" = "list" ] && [ "$2" = "-g" ]; then + # Report a version only if the package binary is actually present in the per-project + # global prefix (pristine npm reports nothing for a package that is not installed yet). + if [ -n "$npm_config_prefix" ] && [ -e "$npm_config_prefix/bin/$3" ]; then + echo "-- $3@9.9.2" + fi + exit 1 +fi +if [ "$1" = "install" ]; then + # Pristine: global packages are installed into the per-project global prefix. + if [ -n "$npm_config_prefix" ]; then + mkdir -p "$npm_config_prefix/bin" + cp target/ide-projects/yarn/repository/yarn/yarn/default/yarn "$npm_config_prefix/bin/yarn" + fi +fi +echo "npm $*" diff --git a/cli/src/test/resources/ide-projects/yarn/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/yarn/repository/npm/npm/default/bin/npx new file mode 100755 index 00000000..0723c204 --- /dev/null +++ b/cli/src/test/resources/ide-projects/yarn/repository/npm/npm/default/bin/npx @@ -0,0 +1,2 @@ +#!/bin/bash +echo "npx $*" From eebe443239b8e45e474cc45ec1f0864d787967ee Mon Sep 17 00:00:00 2001 From: Samuel Kostiuk <148919667+samuelkos17@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:20:53 +0200 Subject: [PATCH 33/66] #2401: Rework Mainwindow (#2437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Co-authored-by: Jörg Hohwiller --- gui/pom.xml | 4 + .../main/java/com/devonfw/ide/gui/App.java | 92 ++-- .../com/devonfw/ide/gui/MainController.java | 463 ------------------ .../context/GuiContextChangeListener.java | 2 +- .../{ => core}/context/GuiOutputListener.java | 4 +- .../{ => core}/context/GuiStateManager.java | 132 ++++- .../gui/{ => core}/context/IdeGuiContext.java | 4 +- .../{ => core}/context/IdeGuiLogListener.java | 4 +- .../{ => core}/context/ProjectManager.java | 2 +- .../gui/{ => core}/context/TaskManager.java | 6 +- .../ide/gui/core/event/GuiEventBus.java | 22 + .../ide/gui/core/event/TabChangeEvent.java | 7 + .../ide/gui/core/factory/TabFactory.java | 58 +++ .../ide/gui/{ => core/helper}/FxHelper.java | 27 +- .../helper/combobox/ComboBoxViewModel.java | 80 +++ .../gui/core/mainwindow/MainWindowView.java | 203 ++++++++ .../core/mainwindow/MainWindowViewModel.java | 168 +++++++ .../console/ConsoleController.java | 13 +- .../navigation/NavigationPanelView.java | 60 +++ .../navigation/NavigationPanelViewModel.java | 142 ++++++ .../ide/gui/{ => core}/modal/IdeDialog.java | 2 +- .../{ => core}/progress/ProgressBarTask.java | 12 +- .../taskwindow/TaskOverviewWindow.java | 7 +- .../TaskOverviewWindowController.java | 6 +- .../taskwindow/TaskWindowCellFactory.java | 4 +- .../gui/core/service/CommandletService.java | 121 +++++ .../gui/{nls => core/service}/NlsService.java | 2 +- .../ide/gui/core/tab/TabComponent.java | 60 +++ .../com/devonfw/ide/gui/core/tab/TabView.java | 23 + .../ide/gui/core/tab/TabViewModel.java | 21 + .../feature/ide/launcher/IdeLauncherTab.java | 58 +++ .../feature/ide/launcher/IdeLauncherView.java | 52 ++ .../ide/launcher/IdeLauncherViewModel.java | 49 ++ .../gui/core/mainwindow/MainWindowView.fxml | 54 ++ .../mainwindow}/console/console.fxml | 15 +- .../navigation/NavigationPanel.fxml | 83 ++++ .../taskwindow}/task_overview_window.fxml | 0 .../gui/feature/ide/launcher/IdeLauncher.fxml | 181 +++++++ .../com/devonfw/ide/gui/main-view.fxml | 335 ------------- .../com/devonfw/ide/gui/style/navigation.css | 2 +- .../main/resources/nls/messages.properties | 2 + .../main/resources/nls/messages_de.properties | 2 + .../java/com/devonfw/ide/gui/AppBaseTest.java | 138 ++++-- .../context/GuiStateManagerTest.java | 8 +- .../context/ProjectManagerTest.java | 2 +- .../ide/gui/core/event/GuiEventBusTest.java | 36 ++ .../ide/gui/core/factory/TabFactoryTest.java | 85 ++++ .../mainwindow}/console/ConsolePanelTest.java | 25 +- .../NavigationPanelViewModelTest.java | 71 +++ .../{ => core}/progress/TaskManagerTest.java | 4 +- .../progress/taskwindow}/TaskWindowTest.java | 12 +- .../{nls => core/service}/NlsServiceTest.java | 2 +- pom.xml | 6 + 53 files changed, 1983 insertions(+), 990 deletions(-) delete mode 100644 gui/src/main/java/com/devonfw/ide/gui/MainController.java rename gui/src/main/java/com/devonfw/ide/gui/{ => core}/context/GuiContextChangeListener.java (89%) rename gui/src/main/java/com/devonfw/ide/gui/{ => core}/context/GuiOutputListener.java (88%) rename gui/src/main/java/com/devonfw/ide/gui/{ => core}/context/GuiStateManager.java (53%) rename gui/src/main/java/com/devonfw/ide/gui/{ => core}/context/IdeGuiContext.java (95%) rename gui/src/main/java/com/devonfw/ide/gui/{ => core}/context/IdeGuiLogListener.java (91%) rename gui/src/main/java/com/devonfw/ide/gui/{ => core}/context/ProjectManager.java (98%) rename gui/src/main/java/com/devonfw/ide/gui/{ => core}/context/TaskManager.java (92%) create mode 100644 gui/src/main/java/com/devonfw/ide/gui/core/event/GuiEventBus.java create mode 100644 gui/src/main/java/com/devonfw/ide/gui/core/event/TabChangeEvent.java create mode 100644 gui/src/main/java/com/devonfw/ide/gui/core/factory/TabFactory.java rename gui/src/main/java/com/devonfw/ide/gui/{ => core/helper}/FxHelper.java (50%) create mode 100644 gui/src/main/java/com/devonfw/ide/gui/core/helper/combobox/ComboBoxViewModel.java create mode 100644 gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/MainWindowView.java create mode 100644 gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/MainWindowViewModel.java rename gui/src/main/java/com/devonfw/ide/gui/{ => core/mainwindow}/console/ConsoleController.java (95%) create mode 100644 gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelView.java create mode 100644 gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelViewModel.java rename gui/src/main/java/com/devonfw/ide/gui/{ => core}/modal/IdeDialog.java (96%) rename gui/src/main/java/com/devonfw/ide/gui/{ => core}/progress/ProgressBarTask.java (91%) rename gui/src/main/java/com/devonfw/ide/gui/{ => core}/progress/taskwindow/TaskOverviewWindow.java (92%) rename gui/src/main/java/com/devonfw/ide/gui/{ => core}/progress/taskwindow/TaskOverviewWindowController.java (88%) rename gui/src/main/java/com/devonfw/ide/gui/{ => core}/progress/taskwindow/TaskWindowCellFactory.java (96%) create mode 100644 gui/src/main/java/com/devonfw/ide/gui/core/service/CommandletService.java rename gui/src/main/java/com/devonfw/ide/gui/{nls => core/service}/NlsService.java (99%) create mode 100644 gui/src/main/java/com/devonfw/ide/gui/core/tab/TabComponent.java create mode 100644 gui/src/main/java/com/devonfw/ide/gui/core/tab/TabView.java create mode 100644 gui/src/main/java/com/devonfw/ide/gui/core/tab/TabViewModel.java create mode 100644 gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherTab.java create mode 100644 gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherView.java create mode 100644 gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherViewModel.java create mode 100644 gui/src/main/resources/com/devonfw/ide/gui/core/mainwindow/MainWindowView.fxml rename gui/src/main/resources/com/devonfw/ide/gui/{ => core/mainwindow}/console/console.fxml (77%) create mode 100644 gui/src/main/resources/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanel.fxml rename gui/src/main/resources/com/devonfw/ide/gui/{ => core/progress/taskwindow}/task_overview_window.fxml (100%) create mode 100644 gui/src/main/resources/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncher.fxml delete mode 100644 gui/src/main/resources/com/devonfw/ide/gui/main-view.fxml rename gui/src/test/java/com/devonfw/ide/gui/{ => core}/context/GuiStateManagerTest.java (91%) rename gui/src/test/java/com/devonfw/ide/gui/{ => core}/context/ProjectManagerTest.java (99%) create mode 100644 gui/src/test/java/com/devonfw/ide/gui/core/event/GuiEventBusTest.java create mode 100644 gui/src/test/java/com/devonfw/ide/gui/core/factory/TabFactoryTest.java rename gui/src/test/java/com/devonfw/ide/gui/{ => core/mainwindow}/console/ConsolePanelTest.java (86%) create mode 100644 gui/src/test/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelViewModelTest.java rename gui/src/test/java/com/devonfw/ide/gui/{ => core}/progress/TaskManagerTest.java (95%) rename gui/src/test/java/com/devonfw/ide/gui/{progress => core/progress/taskwindow}/TaskWindowTest.java (93%) rename gui/src/test/java/com/devonfw/ide/gui/{nls => core/service}/NlsServiceTest.java (99%) diff --git a/gui/pom.xml b/gui/pom.xml index 2d30fe20..983f0ca1 100644 --- a/gui/pom.xml +++ b/gui/pom.xml @@ -52,6 +52,10 @@ org.openjfx javafx-fxml + + io.github.m-m-m + mmm-event + org.testfx testfx-core 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 ab00992f..85750daf 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/App.java +++ b/gui/src/main/java/com/devonfw/ide/gui/App.java @@ -2,14 +2,11 @@ import java.awt.Taskbar; import java.awt.Toolkit; -import java.io.IOException; import java.net.URL; import javafx.application.Application; import javafx.application.Platform; -import javafx.fxml.FXMLLoader; import javafx.geometry.Rectangle2D; -import javafx.scene.Parent; import javafx.scene.Scene; import javafx.scene.control.ButtonType; import javafx.scene.image.Image; @@ -19,13 +16,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.ide.gui.console.ConsoleController; -import com.devonfw.ide.gui.context.GuiStateManager; -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.core.context.GuiStateManager; +import com.devonfw.ide.gui.core.mainwindow.MainWindowView; +import com.devonfw.ide.gui.core.mainwindow.MainWindowViewModel; +import com.devonfw.ide.gui.core.modal.IdeDialog; import com.devonfw.tools.ide.os.SystemInfoImpl; -import com.devonfw.tools.ide.variable.IdeVariables; import com.devonfw.tools.ide.version.IdeVersion; /** @@ -38,19 +33,14 @@ public class App extends Application { */ public static final String ICON_PATH = "com/devonfw/ide/gui/assets/devonfw.png"; - Parent root; - private Stage primaryStage; - private NlsService nlsService; - - TaskManager taskManager = new TaskManager(); - GuiStateManager guiStateManager = new GuiStateManager(taskManager, null); + GuiStateManager guiStateManager = new GuiStateManager(null); private final Logger LOG = LoggerFactory.getLogger(App.class); @Override - public void start(Stage primaryStage) throws IOException { + public void start(Stage primaryStage) { 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()); @@ -58,31 +48,20 @@ public void start(Stage primaryStage) throws IOException { this.primaryStage = primaryStage; - this.nlsService = new NlsService(null); + final MainWindowViewModel mainWindowViewModel = new MainWindowViewModel(guiStateManager); + final MainWindowView mainWindow = new MainWindowView(mainWindowViewModel, guiStateManager); - 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); - - if (SystemInfoImpl.INSTANCE.isMac()) { - setIconInMacOsDock(); - } + Scene scene = new Scene(mainWindow, bounds.getWidth() / 2, bounds.getHeight() / 2); - Image icon = new Image(ICON_PATH); - primaryStage.getIcons().add(icon); - primaryStage.setTitle("IDEasy - version " + IdeVersion.getVersionString()); - primaryStage.setScene(scene); - primaryStage.setMinWidth(scene.getWidth()); - primaryStage.setMinHeight(scene.getHeight()); - primaryStage.show(); + configureMainWindow(scene); primaryStage.setOnCloseRequest(event -> { LOG.info("Closing application"); - if (!taskManager.getTasks().isEmpty()) { + if (!guiStateManager.getTaskManager().getTasks().isEmpty()) { IdeDialog closeConfirm = new IdeDialog(IdeDialog.AlertType.CONFIRMATION, "There are still running tasks. Are you sure you want to exit?", ButtonType.CLOSE, ButtonType.CANCEL); closeConfirm.showAndWait().ifPresent(response -> { @@ -98,6 +77,22 @@ public void start(Stage primaryStage) throws IOException { }); } + private void configureMainWindow(Scene mainWindowScene) { + if (SystemInfoImpl.INSTANCE.isMac()) { + setIconInMacOsDock(); + } + + Image icon = new Image(ICON_PATH); + primaryStage.getIcons().add(icon); + primaryStage.setTitle("IDEasy - version " + IdeVersion.getVersionString()); + primaryStage.setScene(mainWindowScene); + primaryStage.setWidth(mainWindowScene.getWidth()); + primaryStage.setHeight(mainWindowScene.getHeight()); + primaryStage.setMinWidth(mainWindowScene.getWidth()); + primaryStage.setMinHeight(mainWindowScene.getHeight()); + primaryStage.show(); + } + private void exitApplication() { Platform.exit(); @@ -107,36 +102,7 @@ private void exitApplication() { @Override public void stop() { - this.nlsService.removeLocaleChangeListener(this::reloadMainView); - } - - private void reloadMainView() { - - try { - Parent reloadedRoot = loadMainView(); - this.root = reloadedRoot; - if (this.primaryStage != null && this.primaryStage.getScene() != null) { - this.primaryStage.getScene().setRoot(reloadedRoot); - } - } catch (IOException e) { - LOG.error("Failed to reload main view after locale change", e); - } - } - - private Parent loadMainView() throws IOException { - - FXMLLoader fxmlLoader = new FXMLLoader(App.class.getResource("main-view.fxml")); - fxmlLoader.setResources(this.nlsService.getResourceBundle()); - MainController mainController = new MainController(System.getenv(IdeVariables.IDE_ROOT.getName()), guiStateManager, this.nlsService); - fxmlLoader.setControllerFactory(clazz -> { - if (clazz == ConsoleController.class) { - return new ConsoleController(this.nlsService); - } else if (clazz == MainController.class) { - return mainController; - } - return null; - }); - return fxmlLoader.load(); + //this.nlsService.removeLocaleChangeListener(this::reloadMainView); } private void setIconInMacOsDock() { diff --git a/gui/src/main/java/com/devonfw/ide/gui/MainController.java b/gui/src/main/java/com/devonfw/ide/gui/MainController.java deleted file mode 100644 index db831010..00000000 --- a/gui/src/main/java/com/devonfw/ide/gui/MainController.java +++ /dev/null @@ -1,463 +0,0 @@ -package com.devonfw.ide.gui; - -import java.io.FileNotFoundException; -import java.nio.file.NotDirectoryException; -import java.nio.file.Path; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; - -import javafx.application.Platform; -import javafx.collections.ListChangeListener; -import javafx.concurrent.Task; -import javafx.event.ActionEvent; -import javafx.fxml.FXML; -import javafx.scene.control.Alert.AlertType; -import javafx.scene.control.Button; -import javafx.scene.control.ComboBox; -import javafx.scene.control.Label; -import javafx.scene.control.ProgressBar; -import javafx.scene.control.SplitPane; -import javafx.scene.control.SplitPane.Divider; -import javafx.scene.control.ToggleButton; -import javafx.scene.layout.AnchorPane; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.devonfw.ide.gui.console.ConsoleController; -import com.devonfw.ide.gui.context.GuiOutputListener; -import com.devonfw.ide.gui.context.GuiStateManager; -import com.devonfw.ide.gui.context.IdeGuiContext; -import com.devonfw.ide.gui.context.IdeGuiLogListener; -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.taskwindow.TaskOverviewWindow; -import com.devonfw.tools.ide.context.IdeStartContextImpl; -import com.devonfw.tools.ide.log.IdeLogLevel; -import com.devonfw.tools.ide.process.OutputListener; - -/** - * Controller of the main screen of the dashboard GUI. - */ -@SuppressWarnings("unused") -public class MainController { - - private static final Logger LOG = LoggerFactory.getLogger(MainController.class); - - private final GuiStateManager guiStateManager; - private final ProjectManager projectManager; - private final TaskManager taskManager; - - private IdeGuiLogListener guiLogListener; - private OutputListener guiOutputListener; - - @FXML - private ComboBox selectedProject; - - @FXML - private ComboBox selectedWorkspace; - - @FXML - private ComboBox selectedLanguage; - - @FXML - private Button androidStudioOpen; - - @FXML - private Button eclipseOpen; - - @FXML - private Button intellijOpen; - - @FXML - private Button vsCodeOpen; - - @FXML - private SplitPane centerSplitPane; - - @FXML - private Divider centerDivider; - - @FXML - private ToggleButton consolePaneToggleButton; - - @FXML - private ConsoleController consoleController; - - @FXML - private AnchorPane console; - - @FXML - private Label statusLabel; - - @FXML - private ProgressBar statusProgressBar; - - - private final double PROGRESSBAR_VISIBLE_WIDTH = 150.0; - - private final String ideRootPath; - - private final Map languageMap; - - private final NlsService nlsService; - - - /** - * Constructor - * - * @param ideRootPath the IDE_ROOT path - * @param guiStateManager the {@link GuiStateManager} to be used in this application instance - * @param nlsService nlsService instance - */ - public MainController(String ideRootPath, GuiStateManager guiStateManager, NlsService nlsService) { - - LOG.debug("IDE_ROOT path={}", ideRootPath); - this.ideRootPath = ideRootPath; - this.guiStateManager = guiStateManager; - this.taskManager = guiStateManager.getTaskManager(); - this.projectManager = guiStateManager.getProjectManager(); - this.languageMap = new LinkedHashMap<>(); - this.nlsService = nlsService; - - setUpTaskListListener(); - } - - private void setUpTaskListListener() { - - ListChangeListener taskListChangeListener = change -> { - List tasks = taskManager.getTasks(); - - 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); - } - } - }; - taskManager.getTasks().addListener(taskListChangeListener); - } - - /** Guard to ensure {@link #initialize()} runs only once across all FXML files. */ - private boolean initialized; - - @FXML - private void initialize() { - - if (this.initialized) { - return; - } - - setProjectsComboBox(); - initLanguageComboBox(); - selectedWorkspace.setOnAction(this::onWorkspaceSelected); - consolePaneToggleButton.setOnAction(_ -> toggleConsole()); - - centerDivider = centerSplitPane.getDividers().getFirst(); - - centerDivider.positionProperty().addListener((_, _, newVal) -> { - //This is a bit of a weird behaviour in JavaFX, but even if you drag the divider fully down, - // the position value does not become 1, but something like 0.9935345 - consolePaneToggleButton.setSelected(newVal.doubleValue() < 0.99); - }); - this.initialized = true; - } - - private void onWorkspaceSelected(ActionEvent actionEvent) { - - String workspaceName = selectedWorkspace.getValue(); - if (workspaceName == null) { - return; - } - updateContext(selectedProject.getValue(), workspaceName); - setIdeButtonsDisabled(false); - } - - private void initLanguageComboBox() { - - this.languageMap.clear(); - selectedLanguage.getItems().clear(); - - for (Locale locale : nlsService.getAvailableLocales()) { - String displayName = nlsService.getLanguageDisplayName(locale); - this.languageMap.put(displayName, locale); - } - - selectedLanguage.getItems().addAll(this.languageMap.keySet()); - //initial value - selectedLanguage.setValue(resolveLanguageSelection(nlsService.getLocale())); - - selectedLanguage.setOnAction(ev -> { - String selection = selectedLanguage.getValue(); - Locale newLocale = this.languageMap.get(selection); - if (newLocale != null) { - nlsService.setLocale(newLocale); - } - }); - } - - private String resolveLanguageSelection(Locale currentLocale) { - - if (currentLocale == null) { - return this.languageMap.keySet().stream().findFirst().orElse(null); - } - - String languageTagMatch = null; - String languageMatch = null; - - for (Map.Entry entry : this.languageMap.entrySet()) { - Locale entryLocale = entry.getValue(); - // Exact language tag match takes priority - if (entryLocale.toLanguageTag().equalsIgnoreCase(currentLocale.toLanguageTag())) { - return entry.getKey(); - } - // Track language-only match as fallback - if (languageMatch == null && entryLocale.getLanguage().equalsIgnoreCase(currentLocale.getLanguage())) { - languageMatch = entry.getKey(); - } - } - - // Return language-only match if found, otherwise first available - return languageMatch != null ? languageMatch : this.languageMap.keySet().stream().findFirst().orElse(null); - } - - - @FXML - private void openAndroidStudio() { - - runCommandlet("android-studio"); - } - - @FXML - private void openEclipse() { - - runCommandlet("eclipse"); - } - - @FXML - private void openIntellij() { - - runCommandlet("intellij"); - } - - @FXML - private void openVsCode() { - - runCommandlet("vscode"); - } - - private void setProjectsComboBox() { - - assert (ideRootPath != null) : "directoryPath is null! Please check the setup of your environment variables (IDE_ROOT)"; - - List projects = projectManager.getProjectNames(); - - selectedProject.getItems().clear(); - selectedProject.getItems().addAll(projects); - - selectedProject.setOnAction(_ -> { - - setWorkspaceComboBox(); - - selectedWorkspace.setDisable(false); - }); - } - - private void setWorkspaceComboBox() { - - List workspaces; - try { - workspaces = projectManager.getWorkspaceNames(selectedProject.getValue()); - } catch (NotDirectoryException e) { - throw new RuntimeException(e); - } - - selectedWorkspace.setValue(null); - selectedWorkspace.getItems().clear(); - selectedWorkspace.getItems().addAll(workspaces); - - if (workspaces.contains("main")) { - selectedWorkspace.setValue("main"); - updateContext(selectedProject.getValue(), selectedWorkspace.getValue()); - setIdeButtonsDisabled(false); - } else { - setIdeButtonsDisabled(true); - } - } - - private void runCommandlet(String commandlet) { - - showConsole(); - Task commandletTask = runIdeCommandTask(commandlet); - - this.guiLogListener = new IdeGuiLogListener(consoleController); - this.guiOutputListener = new GuiOutputListener(consoleController); - - Thread commandletThread = new Thread(commandletTask); - commandletThread.setDaemon(true); - commandletThread.start(); - } - - private Task runIdeCommandTask(String commandlet) { - - try (ProgressBarTask task = (ProgressBarTask) guiStateManager.getCurrentContext() - .newProgressBarIndeterminate("Starting " + commandlet)) { - Task downloadTask = new Task<>() { - @Override - protected Void call() { - - try { - IdeStartContextImpl startContext = new IdeStartContextImpl(IdeLogLevel.INFO, guiLogListener); - IdeGuiContext context = new IdeGuiContext(startContext, - Path.of(ideRootPath).resolve(selectedProject.getValue()).resolve(selectedWorkspace.getValue()), taskManager); - - // Set output listener for process output - context.setOutputListener(guiOutputListener); - - LOG.info("[GUI] === Running {} ===", commandlet); - - context.getCommandletManager().getCommandlet(commandlet).run(); - - LOG.info("[GUI] === {} completed successfully. ===", commandlet); - } catch (Exception e) { - LOG.error("Failed to open {}", commandlet, e); - consoleController.appendOutput("[ERROR] Failed to launch " + commandlet + ": " + e.getMessage()); - } - return null; - } - }; - - downloadTask.setOnFailed(_ -> Platform.runLater(() -> { - task.close(); - IdeDialog errorDialog = new IdeDialog(AlertType.ERROR, "Error occurred while launching " + commandlet); - errorDialog.showAndWait(); - })); - downloadTask.setOnSucceeded(_ -> Platform.runLater(task::close)); - return downloadTask; - } - } - - private void updateContext(String selectedProjectName, String selectedWorkspaceName) { - - try { - guiStateManager.switchContext(selectedProjectName, selectedWorkspaceName); - } catch (FileNotFoundException e) { - IdeDialog errorDialog = new IdeDialog(AlertType.ERROR, e.getMessage()); - errorDialog.showAndWait(); - } - } - - /** - * Toggles the console visibility - */ - public void toggleConsole() { - - if (centerSplitPane != null) { - if (isConsoleVisible()) { - hideConsole(); - } else { - showConsole(); - } - consolePaneToggleButton.setSelected(isConsoleVisible()); - } - } - - /** - * Hides the console panel - */ - public void hideConsole() { - - if (centerSplitPane != null) { - centerSplitPane.setDividerPosition(0, 1.0); - LOG.debug("Console hidden"); - } - } - - /** - * Shows the console panel - */ - public void showConsole() { - - if (centerSplitPane != null) { - if (centerSplitPane.getDividers().getFirst().getPosition() >= 0.9) { - centerSplitPane.setDividerPosition(0, 0.75); - } - LOG.debug("Console shown"); - } - } - - private boolean isConsoleVisible() { - return centerDivider.getPosition() <= 0.99 && console.isVisible(); - } - - private void updateStatusLabel(List taskList) { - - Platform.runLater(() -> { - - if (taskList.size() > 1) { - statusLabel.setOnMouseClicked(e -> TaskOverviewWindow.getInstance(taskManager).showRelativeToReferenceNode(statusLabel)); - - statusProgressBar.setVisible(false); - statusProgressBar.setPrefWidth(0); - statusLabel.setText(taskList.size() + " tasks running..."); - - statusLabel.setUnderline(true); - statusLabel.setStyle( - "-fx-text-fill: blue;" - + "-fx-cursor: hand" - ); - } else if (taskList.size() == 1) { - statusLabel.setOnMouseClicked(null); - - ProgressBarTask task = taskList.getFirst(); - statusLabel.setText(String.format( - ProgressBarTask.TASK_DESCRIPTION_STRING_FORMAT, - task.getTitle(), - task.getCurrentProgress(), - task.getMaxSize(), - task.getUnitName()) - ); - statusLabel.setUnderline(false); - statusLabel.setStyle(""); - - statusProgressBar.setVisible(true); - statusProgressBar.setPrefWidth(PROGRESSBAR_VISIBLE_WIDTH); - statusProgressBar.setProgress((double) (task.getCurrentProgress()) / task.getMaxSize()); - } else { - statusLabel.setOnMouseClicked(null); - statusLabel.setText("IDEasy is ready."); - statusProgressBar.setVisible(false); - statusProgressBar.setPrefWidth(0); - - statusLabel.setUnderline(false); - statusLabel.setStyle(""); - } - }); - } - - private void setIdeButtonsDisabled(boolean disabled) { - - this.androidStudioOpen.setDisable(disabled); - this.eclipseOpen.setDisable(disabled); - this.intellijOpen.setDisable(disabled); - this.vsCodeOpen.setDisable(disabled); - } -} diff --git a/gui/src/main/java/com/devonfw/ide/gui/context/GuiContextChangeListener.java b/gui/src/main/java/com/devonfw/ide/gui/core/context/GuiContextChangeListener.java similarity index 89% rename from gui/src/main/java/com/devonfw/ide/gui/context/GuiContextChangeListener.java rename to gui/src/main/java/com/devonfw/ide/gui/core/context/GuiContextChangeListener.java index 53972265..c33616dd 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/context/GuiContextChangeListener.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/context/GuiContextChangeListener.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.context; +package com.devonfw.ide.gui.core.context; /** * Interface that notifies listeners of context changes. diff --git a/gui/src/main/java/com/devonfw/ide/gui/context/GuiOutputListener.java b/gui/src/main/java/com/devonfw/ide/gui/core/context/GuiOutputListener.java similarity index 88% rename from gui/src/main/java/com/devonfw/ide/gui/context/GuiOutputListener.java rename to gui/src/main/java/com/devonfw/ide/gui/core/context/GuiOutputListener.java index 5bfe4a34..2c23ad3f 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/context/GuiOutputListener.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/context/GuiOutputListener.java @@ -1,8 +1,8 @@ -package com.devonfw.ide.gui.context; +package com.devonfw.ide.gui.core.context; import javafx.application.Platform; -import com.devonfw.ide.gui.console.ConsoleController; +import com.devonfw.ide.gui.core.mainwindow.console.ConsoleController; import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.process.OutputListener; diff --git a/gui/src/main/java/com/devonfw/ide/gui/context/GuiStateManager.java b/gui/src/main/java/com/devonfw/ide/gui/core/context/GuiStateManager.java similarity index 53% rename from gui/src/main/java/com/devonfw/ide/gui/context/GuiStateManager.java rename to gui/src/main/java/com/devonfw/ide/gui/core/context/GuiStateManager.java index 91e5789e..0ba75876 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/context/GuiStateManager.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/context/GuiStateManager.java @@ -1,13 +1,23 @@ -package com.devonfw.ide.gui.context; +package com.devonfw.ide.gui.core.context; import java.io.FileNotFoundException; import java.nio.file.Files; import java.nio.file.Path; import java.util.concurrent.CopyOnWriteArrayList; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.devonfw.ide.gui.core.event.GuiEventBus; +import com.devonfw.ide.gui.core.factory.TabFactory; +import com.devonfw.ide.gui.core.mainwindow.console.ConsoleController; +import com.devonfw.ide.gui.core.service.CommandletService; +import com.devonfw.ide.gui.core.service.NlsService; import com.devonfw.tools.ide.context.IdeStartContextImpl; import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.log.IdeLogListenerBuffer; @@ -20,32 +30,52 @@ public class GuiStateManager { private static final Logger LOG = LoggerFactory.getLogger(GuiStateManager.class); + public static final String DEFAULT_WORKSPACE = "main"; + private final Path ideRootDir; - private final ProjectManager projectManager; private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); - /** - * Project context based on which project the user works in. - */ + private final StringProperty selectedProject = new SimpleStringProperty(); + + private final StringProperty selectedWorkspace = new SimpleStringProperty(); + + private final BooleanProperty workspaceSelected = new SimpleBooleanProperty(false); + 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 final ProjectManager projectManager; + private final TaskManager taskManager; + private final GuiEventBus eventBus; + + private final NlsService nlsService; + + private final ConsoleController consoleController; + + private final CommandletService commandletService; + + private final TabFactory tabFactory; + + /** - * @param taskManager the {@link TaskManager} that manages any running tasks in the GUI. * @param ideRoot the root directory of IDEasy. If null, the IDE_ROOT environment variable is used. */ - public GuiStateManager(TaskManager taskManager, String ideRoot) { + public GuiStateManager(String ideRoot) { - this.taskManager = taskManager; this.ideRootDir = Path.of(ideRoot != null ? ideRoot : System.getenv(IdeVariables.IDE_ROOT.getName())); + + //This specific ordering needs to be kept this.projectManager = new ProjectManager(ideRootDir); + this.taskManager = new TaskManager(); + this.eventBus = new GuiEventBus(); + this.nlsService = new NlsService(null); + this.consoleController = new ConsoleController(this); + this.commandletService = new CommandletService(this); + this.tabFactory = new TabFactory(this); final IdeLogListenerBuffer buffer = new IdeLogListenerBuffer(); IdeLogLevel logLevel = IdeLogLevel.DEBUG; @@ -71,6 +101,10 @@ public synchronized IdeGuiContext switchContext(String projectName, String works throw new FileNotFoundException("Workspace " + workspacePath + " does not exist!"); } + this.selectedProject.set(projectName); + this.selectedWorkspace.set(workspaceName); + this.workspaceSelected.set(true); + this.currentContext = new IdeGuiContext(startContext, workspacePath, taskManager); listeners.forEach(listener -> listener.onContextChange(this.currentContext)); @@ -93,6 +127,62 @@ public ProjectManager getProjectManager() { return projectManager; } + /** + * @return the currently selected project name (app-wide, single selection). + */ + public String getSelectedProject() { + + return this.selectedProject.get(); + } + + /** + * @return the currently selected workspace name (app-wide, single selection). + */ + public String getSelectedWorkspace() { + + return this.selectedWorkspace.get(); + } + + /** + * @return the observable {@link StringProperty} holding the selected project (app-wide, single selection). + */ + public StringProperty selectedProjectProperty() { + + return this.selectedProject; + } + + /** + * @return the observable {@link StringProperty} holding the selected workspace (app-wide, single selection). + */ + public StringProperty selectedWorkspaceProperty() { + + return this.selectedWorkspace; + } + + /** + * @return whether a valid project/workspace selection is active. + */ + public boolean isWorkspaceSelected() { + + return this.workspaceSelected.get(); + } + + /** + * @return the observable {@link BooleanProperty} flagging whether a valid project/workspace selection is active. + */ + public BooleanProperty workspaceSelectedProperty() { + + return this.workspaceSelected; + } + + /** + * @return the root directory of IDEasy used by the GUI. + */ + public Path getIdeRootDir() { + + return this.ideRootDir; + } + /** * @return instance of {@link TaskManager} */ @@ -118,4 +208,24 @@ public void addGuiContextChangeListener(GuiContextChangeListener listener) { public void removeGuiContextChangeListener(GuiContextChangeListener listener) { listeners.remove(listener); } + + public GuiEventBus getEventBus() { + return eventBus; + } + + public NlsService getNlsService() { + return nlsService; + } + + public ConsoleController getConsoleController() { + return consoleController; + } + + public CommandletService getCommandletService() { + return commandletService; + } + + public TabFactory getTabFactory() { + return tabFactory; + } } diff --git a/gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiContext.java b/gui/src/main/java/com/devonfw/ide/gui/core/context/IdeGuiContext.java similarity index 95% rename from gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiContext.java rename to gui/src/main/java/com/devonfw/ide/gui/core/context/IdeGuiContext.java index 35f3a3b6..e655a5bd 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiContext.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/context/IdeGuiContext.java @@ -1,9 +1,9 @@ -package com.devonfw.ide.gui.context; +package com.devonfw.ide.gui.core.context; import java.nio.file.Path; import java.util.UUID; -import com.devonfw.ide.gui.progress.ProgressBarTask; +import com.devonfw.ide.gui.core.progress.ProgressBarTask; import com.devonfw.tools.ide.context.AbstractIdeContext; import com.devonfw.tools.ide.context.IdeStartContextImpl; import com.devonfw.tools.ide.io.IdeProgressBar; diff --git a/gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiLogListener.java b/gui/src/main/java/com/devonfw/ide/gui/core/context/IdeGuiLogListener.java similarity index 91% rename from gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiLogListener.java rename to gui/src/main/java/com/devonfw/ide/gui/core/context/IdeGuiLogListener.java index 51d7e3a0..e4701f18 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiLogListener.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/context/IdeGuiLogListener.java @@ -1,7 +1,7 @@ -package com.devonfw.ide.gui.context; +package com.devonfw.ide.gui.core.context; -import com.devonfw.ide.gui.console.ConsoleController; +import com.devonfw.ide.gui.core.mainwindow.console.ConsoleController; import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.log.IdeLogListenerBuffer; diff --git a/gui/src/main/java/com/devonfw/ide/gui/context/ProjectManager.java b/gui/src/main/java/com/devonfw/ide/gui/core/context/ProjectManager.java similarity index 98% rename from gui/src/main/java/com/devonfw/ide/gui/context/ProjectManager.java rename to gui/src/main/java/com/devonfw/ide/gui/core/context/ProjectManager.java index 68290aa3..073acb3f 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/context/ProjectManager.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/context/ProjectManager.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.context; +package com.devonfw.ide.gui.core.context; import java.io.IOException; import java.nio.file.Files; diff --git a/gui/src/main/java/com/devonfw/ide/gui/context/TaskManager.java b/gui/src/main/java/com/devonfw/ide/gui/core/context/TaskManager.java similarity index 92% rename from gui/src/main/java/com/devonfw/ide/gui/context/TaskManager.java rename to gui/src/main/java/com/devonfw/ide/gui/core/context/TaskManager.java index 1ac8da65..c72f8b5c 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/context/TaskManager.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/context/TaskManager.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.context; +package com.devonfw.ide.gui.core.context; import java.util.Objects; @@ -8,8 +8,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.ide.gui.FxHelper; -import com.devonfw.ide.gui.progress.ProgressBarTask; +import com.devonfw.ide.gui.core.helper.FxHelper; +import com.devonfw.ide.gui.core.progress.ProgressBarTask; /** * Singleton class that manages all currently running tasks and their progress bars. It provides an {@link ObservableList} of tasks, which can be observed by diff --git a/gui/src/main/java/com/devonfw/ide/gui/core/event/GuiEventBus.java b/gui/src/main/java/com/devonfw/ide/gui/core/event/GuiEventBus.java new file mode 100644 index 00000000..27e8424b --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/core/event/GuiEventBus.java @@ -0,0 +1,22 @@ +package com.devonfw.ide.gui.core.event; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.ide.gui.core.helper.FxHelper; + +import io.github.mmm.event.impl.EventBusImpl; + +/** + * Event bus of the GUI. It dispatches on the thread that sends the event and performs no JavaFX-thread marshalling, so it is threading-neutral and can be used + * for non-GUI logic as well. A listener that touches the scene graph has to hop to the FX thread itself (see {@link FxHelper#runFxSafe(Runnable)}). + */ +public class GuiEventBus extends EventBusImpl { + + private static final Logger LOG = LoggerFactory.getLogger(GuiEventBus.class); + + public GuiEventBus() { + + super((context, error) -> LOG.error("Error while handling event {}: {}", context, error.getMessage(), error)); + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/core/event/TabChangeEvent.java b/gui/src/main/java/com/devonfw/ide/gui/core/event/TabChangeEvent.java new file mode 100644 index 00000000..be36ea14 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/core/event/TabChangeEvent.java @@ -0,0 +1,7 @@ +package com.devonfw.ide.gui.core.event; + +import javafx.scene.control.Tab; + +public record TabChangeEvent(Tab tab) { + +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/core/factory/TabFactory.java b/gui/src/main/java/com/devonfw/ide/gui/core/factory/TabFactory.java new file mode 100644 index 00000000..5527d7d8 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/core/factory/TabFactory.java @@ -0,0 +1,58 @@ +package com.devonfw.ide.gui.core.factory; + +import java.util.Objects; + +import javafx.scene.control.TabPane; + +import com.devonfw.ide.gui.core.context.GuiStateManager; +import com.devonfw.ide.gui.core.service.CommandletService; +import com.devonfw.ide.gui.core.tab.TabComponent; +import com.devonfw.ide.gui.feature.ide.launcher.IdeLauncherTab; + +import io.github.mmm.event.EventBus; + +/** + * Creates the tabs of the main window's {@link TabPane}. It keeps at most one tab open per title key, so re-activating a tab focuses the existing one instead + * of creating a duplicate. The managing part is to be removed in the future. + */ +public class TabFactory { + + private final GuiStateManager guiStateManager; + private final CommandletService commandletService; + private final EventBus eventBus; + + /** + * Creates the factory. + * + * @param guiStateManager the app-wide selection and context holder. + */ + public TabFactory(GuiStateManager guiStateManager) { + this.guiStateManager = Objects.requireNonNull(guiStateManager); + this.commandletService = Objects.requireNonNull(guiStateManager.getCommandletService()); + this.eventBus = Objects.requireNonNull(guiStateManager.getEventBus()); + } + + + /** + * Registers an action that runs before any commandlet launched from a tab (e.g. to show the console). This is a hack for the console auto-showing when + * starting an IDE and should be removed when the console is reworked. + * + * @param preLaunchAction the action to run before a commandlet launches. + */ + public void setPreLaunchAction(Runnable preLaunchAction) { + this.commandletService.setPreLaunchAction(preLaunchAction); + } + + + /** + * Opens (or focuses) the IDE launcher tab. + */ + public void openLauncherTab() { + open(new IdeLauncherTab(guiStateManager)); + } + + private void open(TabComponent tabComponent) { + this.eventBus.sendEvent(tabComponent.createTabChangeEvent()); + } + +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/FxHelper.java b/gui/src/main/java/com/devonfw/ide/gui/core/helper/FxHelper.java similarity index 50% rename from gui/src/main/java/com/devonfw/ide/gui/FxHelper.java rename to gui/src/main/java/com/devonfw/ide/gui/core/helper/FxHelper.java index 549a315a..04d63f7a 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/FxHelper.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/helper/FxHelper.java @@ -1,8 +1,14 @@ -package com.devonfw.ide.gui; +package com.devonfw.ide.gui.core.helper; + +import java.io.IOException; import javafx.application.Platform; +import javafx.fxml.FXMLLoader; +import javafx.scene.Node; import javafx.scene.Parent; +import com.devonfw.ide.gui.core.service.NlsService; + /** * Helper class containing tools for interacting with JavaFX */ @@ -31,4 +37,23 @@ public static T lookup(Parent root, String selector) { return (T) root.lookup(selector); } + + /** + * Loads the given FXML file into the given root node, using the {@link NlsService}'s resource bundle to resolve the {@code %key} text. + * + * @param fxmlName the FXML file name, resolved relative to the root node's class. + * @param nlsService the localization service whose resource bundle resolves the text keys. + * @param root the node that is both the FXML root and its controller. + */ + public static void loadFxml(String fxmlName, NlsService nlsService, Node root) { + final FXMLLoader loader = new FXMLLoader(root.getClass().getResource(fxmlName)); + loader.setRoot(root); + loader.setController(root); + loader.setResources(nlsService.getResourceBundle()); + try { + loader.load(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } } diff --git a/gui/src/main/java/com/devonfw/ide/gui/core/helper/combobox/ComboBoxViewModel.java b/gui/src/main/java/com/devonfw/ide/gui/core/helper/combobox/ComboBoxViewModel.java new file mode 100644 index 00000000..a5d7fdb5 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/core/helper/combobox/ComboBoxViewModel.java @@ -0,0 +1,80 @@ +package com.devonfw.ide.gui.core.helper.combobox; + +import java.util.Optional; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.scene.control.ComboBox; + +/** + * Small view model for a {@link ComboBox}, exposing the items, the selected item and a disabled flag as observable properties. + */ +public class ComboBoxViewModel { + + private final ObservableList items = FXCollections.observableArrayList(); + private final ObjectProperty selectedItem = new SimpleObjectProperty<>(); + private final BooleanProperty isDisabled = new SimpleBooleanProperty(); + + /** + * Selects the given item if it is one of the items. + * + * @param item the item to select. + * @return whether the item was present and got selected. + */ + public boolean select(T item) { + if (items.stream().anyMatch(i -> i.equals(item))) { + selectedItem.set(item); + return true; + } + return false; + } + + /** + * Clears the current selection. + */ + public void clearSelection() { + this.selectedItem.set(null); + } + + /** + * Selects the first item. + */ + public void selectFirst() { + if (items.size() > 0) { + selectedItem.set(items.get(0)); + } + } + + /** + * @return the selected item, or an empty {@link Optional} if none is selected. + */ + public Optional getSelectedItem() { + return Optional.ofNullable(selectedItem.get()); + } + + /** + * @return the items shown in the combo box. + */ + public final ObservableList getItems() { + return items; + } + + /** + * @return the property holding the selected item. + */ + public final ObjectProperty selectedItemProperty() { + return selectedItem; + } + + /** + * @return the property flagging whether the combo box is disabled. + */ + public final BooleanProperty isDisabledProperty() { + return isDisabled; + } + +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/MainWindowView.java b/gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/MainWindowView.java new file mode 100644 index 00000000..0fd3d797 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/MainWindowView.java @@ -0,0 +1,203 @@ +package com.devonfw.ide.gui.core.mainwindow; + +import java.io.IOException; +import java.util.Objects; + +import javafx.beans.binding.Bindings; +import javafx.fxml.FXML; +import javafx.fxml.FXMLLoader; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressBar; +import javafx.scene.control.SplitPane; +import javafx.scene.control.SplitPane.Divider; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; +import javafx.scene.control.ToggleButton; +import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.BorderPane; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.ide.gui.core.context.GuiStateManager; +import com.devonfw.ide.gui.core.event.TabChangeEvent; +import com.devonfw.ide.gui.core.helper.FxHelper; +import com.devonfw.ide.gui.core.mainwindow.console.ConsoleController; +import com.devonfw.ide.gui.core.mainwindow.navigation.NavigationPanelView; +import com.devonfw.ide.gui.core.mainwindow.navigation.NavigationPanelViewModel; +import com.devonfw.ide.gui.core.progress.taskwindow.TaskOverviewWindow; +import com.devonfw.ide.gui.core.service.NlsService; + +import io.github.mmm.event.EventBus; + +/** + * View of the main window. It renders what the {@link MainWindowViewModel} exposes and translates user gestures back onto it; all selection state, status + * computation and context switching live in the view model. + */ +public class MainWindowView extends BorderPane { + + private static final Logger LOG = LoggerFactory.getLogger(MainWindowView.class); + + /** Divider position that reveals the console panel. */ + private static final double CONSOLE_SHOWN_DIVIDER_POSITION = 0.75; + + /** Divider position that collapses the console panel. */ + private static final double CONSOLE_HIDDEN_DIVIDER_POSITION = 1.0; + + /** + * Threshold below which the console counts as visible. Dragging the divider fully down does not yield exactly 1.0 in JavaFX, but something like 0.9935, so an + * exact comparison would never report the console as hidden. + */ + private static final double CONSOLE_HIDDEN_THRESHOLD = 0.99; + + /** Only expand the console when the divider is (nearly) collapsed, so an already open console keeps the size the user chose. */ + private static final double CONSOLE_COLLAPSED_THRESHOLD = 0.9; + + private static final double PROGRESSBAR_VISIBLE_WIDTH = 150.0; + + @FXML + private TabPane tabPane; + + @FXML + private SplitPane centerSplitPane; + + @FXML + private ToggleButton consolePaneToggleButton; + + @FXML + private AnchorPane console; + + @FXML + private Label statusLabel; + + @FXML + private ProgressBar statusProgressBar; + + private final MainWindowViewModel viewModel; + + private final NlsService nlsService; + + private final ConsoleController consoleController; + + private final EventBus eventBus; + + private final Divider centerDivider; + + private final GuiStateManager guiStateManager; + + private boolean propagatingDividerPosition; + + /** + * Builds the main window, loads its FXML, constructs the navigation panel, and binds the console and status bar to the given {@link MainWindowViewModel}. + * + * @param viewModel the view model holding the selection and status state. + * @param guiStateManager the app-wide selection and context holder. + */ + public MainWindowView(MainWindowViewModel viewModel, GuiStateManager guiStateManager) { + + super(); + + this.viewModel = Objects.requireNonNull(viewModel); + this.guiStateManager = Objects.requireNonNull(guiStateManager); + this.nlsService = Objects.requireNonNull(guiStateManager.getNlsService()); + this.consoleController = Objects.requireNonNull(guiStateManager.getConsoleController()); + this.eventBus = Objects.requireNonNull(guiStateManager.getEventBus()); + + final FXMLLoader loader = new FXMLLoader(getClass().getResource("MainWindowView.fxml")); + loader.setRoot(this); + loader.setController(this); + loader.setResources(this.nlsService.getResourceBundle()); + loader.setControllerFactory(clazz -> clazz == ConsoleController.class ? this.consoleController : null); + + try { + loader.load(); + } catch (IOException e) { + throw new RuntimeException(e); + } + + final NavigationPanelViewModel navigationPanelViewModel = new NavigationPanelViewModel(this.guiStateManager); + setLeft(new NavigationPanelView(navigationPanelViewModel, this.nlsService)); + + this.eventBus.addListener(TabChangeEvent.class, e -> FxHelper.runFxSafe(() -> handleTab(e.tab()))); + + this.centerDivider = this.centerSplitPane.getDividers().getFirst(); + + bindConsole(); + bindStatusBar(); + + this.viewModel.openLauncherTab(); + } + + private void handleTab(Tab tab) { + + if (!this.tabPane.getTabs().contains(tab)) { + this.tabPane.getTabs().add(tab); + } + this.tabPane.getSelectionModel().select(tab); + } + + private void bindConsole() { + + this.centerDivider.positionProperty().addListener((_, _, position) -> { + this.propagatingDividerPosition = true; + try { + // The console pane is never hidden today, but the original visibility check is kept so dragging cannot report a hidden pane as shown. + this.viewModel.setConsoleVisible(position.doubleValue() < CONSOLE_HIDDEN_THRESHOLD && this.console.isVisible()); + } finally { + this.propagatingDividerPosition = false; + } + }); + this.viewModel.consoleVisibleProperty().addListener((_, _, visible) -> { + if (!this.propagatingDividerPosition) { + applyConsoleVisibility(visible); + } + }); + this.consolePaneToggleButton.selectedProperty().bindBidirectional(this.viewModel.consoleVisibleProperty()); + } + + private void applyConsoleVisibility(boolean visible) { + + if (visible) { + if (this.centerDivider.getPosition() >= CONSOLE_COLLAPSED_THRESHOLD) { + this.centerSplitPane.setDividerPosition(0, CONSOLE_SHOWN_DIVIDER_POSITION); + } + LOG.debug("Console shown"); + } else { + this.centerSplitPane.setDividerPosition(0, CONSOLE_HIDDEN_DIVIDER_POSITION); + LOG.debug("Console hidden"); + } + } + + private void bindStatusBar() { + + this.statusLabel.textProperty().bind(this.viewModel.statusTextProperty()); + this.statusLabel.underlineProperty().bind(this.viewModel.statusClickableProperty()); + this.statusProgressBar.visibleProperty().bind(this.viewModel.statusProgressVisibleProperty()); + this.statusProgressBar.progressProperty().bind(this.viewModel.statusProgressProperty()); + + applyStatusLabelStyle(this.viewModel.statusClickableProperty().get()); + this.viewModel.statusClickableProperty().addListener((_, _, clickable) -> applyStatusLabelStyle(clickable)); + + this.statusProgressBar.prefWidthProperty().bind( + Bindings.when(this.viewModel.statusProgressVisibleProperty()).then(PROGRESSBAR_VISIBLE_WIDTH).otherwise(0.0)); + + this.statusLabel.setOnMouseClicked(_ -> { + if (this.viewModel.statusClickableProperty().get()) { + TaskOverviewWindow.getInstance(this.guiStateManager.getTaskManager()).showRelativeToReferenceNode(this.statusLabel); + } + }); + } + + private void applyStatusLabelStyle(boolean clickable) { + + if (clickable) { + this.statusLabel.setStyle( + "-fx-text-fill: blue;" + + "-fx-cursor: hand" + ); + } else { + this.statusLabel.setStyle(""); + } + } + +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/MainWindowViewModel.java b/gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/MainWindowViewModel.java new file mode 100644 index 00000000..c811312a --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/MainWindowViewModel.java @@ -0,0 +1,168 @@ +package com.devonfw.ide.gui.core.mainwindow; + +import java.util.List; +import java.util.Objects; + +import javafx.application.Platform; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.ListChangeListener; + +import com.devonfw.ide.gui.core.context.GuiStateManager; +import com.devonfw.ide.gui.core.factory.TabFactory; +import com.devonfw.ide.gui.core.progress.ProgressBarTask; +import com.devonfw.ide.gui.core.service.NlsService; + +/** + * View model of the main window. It holds the observable state behind the status bar (status text, progress and clickability) and the console visibility, and + * reflects the task list held by the {@link GuiStateManager} onto the status bar. + */ +public class MainWindowViewModel { + + private final GuiStateManager guiStateManager; + + private final NlsService nlsService; + + private final TabFactory tabFactory; + + private final StringProperty statusText = new SimpleStringProperty(); + + private final BooleanProperty statusProgressVisible = new SimpleBooleanProperty(false); + + private final DoubleProperty statusProgress = new SimpleDoubleProperty(0); + + private final BooleanProperty statusClickable = new SimpleBooleanProperty(false); + + private final BooleanProperty consoleVisible = new SimpleBooleanProperty(false); + + /** + * Initializes the view model and wires the task-list listener that drives the status bar. + * + * @param guiStateManager the app-wide selection and context holder. + */ + public MainWindowViewModel(GuiStateManager guiStateManager) { + + this.guiStateManager = Objects.requireNonNull(guiStateManager); + this.nlsService = Objects.requireNonNull(guiStateManager.getNlsService()); + this.tabFactory = Objects.requireNonNull(guiStateManager.getTabFactory()); + this.tabFactory.setPreLaunchAction(() -> this.setConsoleVisible(true)); + + this.statusText.set(this.nlsService.get("ideasy_ready_msg")); + + setUpTaskListListener(); + } + + private void setUpTaskListListener() { + + ListChangeListener taskListChangeListener = change -> { + List tasks = this.guiStateManager.getTaskManager().getTasks(); + + while (change.next()) { + if (change.wasAdded()) { + + for (ProgressBarTask progressTask : change.getAddedSubList()) { + progressTask.currentProgressProperty().addListener((_, _, _) -> + updateStatus(tasks) + ); + } + updateStatus(tasks); + } else if (change.wasRemoved()) { + + updateStatus(tasks); + } else if (change.wasUpdated()) { + + updateStatus(tasks); + } + } + }; + this.guiStateManager.getTaskManager().getTasks().addListener(taskListChangeListener); + } + + private void updateStatus(List taskList) { + + Platform.runLater(() -> { + + if (taskList.size() > 1) { + this.statusText.set(taskList.size() + " tasks running..."); + this.statusClickable.set(true); + this.statusProgressVisible.set(false); + } else if (taskList.size() == 1) { + ProgressBarTask task = taskList.getFirst(); + this.statusText.set(String.format( + ProgressBarTask.TASK_DESCRIPTION_STRING_FORMAT, + task.getTitle(), + task.getCurrentProgress(), + task.getMaxSize(), + task.getUnitName()) + ); + this.statusClickable.set(false); + this.statusProgressVisible.set(true); + this.statusProgress.set((double) (task.getCurrentProgress()) / task.getMaxSize()); + } else { + this.statusText.set(this.nlsService.get("ideasy_ready_msg")); + this.statusClickable.set(false); + this.statusProgressVisible.set(false); + } + }); + } + + /** + * @return the property holding the status bar text. + */ + public StringProperty statusTextProperty() { + + return this.statusText; + } + + /** + * @return the property flagging whether the status bar progress indicator is visible. + */ + public BooleanProperty statusProgressVisibleProperty() { + + return this.statusProgressVisible; + } + + /** + * @return the property holding the progress of the currently running task (0 to 1). + */ + public DoubleProperty statusProgressProperty() { + + return this.statusProgress; + } + + /** + * @return the property flagging whether the status bar is clickable to open the task overview. + */ + public BooleanProperty statusClickableProperty() { + + return this.statusClickable; + } + + /** + * @return the property flagging whether the console pane is visible. + */ + public BooleanProperty consoleVisibleProperty() { + + return this.consoleVisible; + } + + /** + * Sets whether the console pane is visible. + * + * @param visible the new visibility of the console pane. + */ + public void setConsoleVisible(boolean visible) { + + this.consoleVisible.set(visible); + } + + public void openLauncherTab() { + + this.tabFactory.openLauncherTab(); + } + +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/console/ConsoleController.java b/gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/console/ConsoleController.java similarity index 95% rename from gui/src/main/java/com/devonfw/ide/gui/console/ConsoleController.java rename to gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/console/ConsoleController.java index 265542a0..c05b6ce5 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/console/ConsoleController.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/console/ConsoleController.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.console; +package com.devonfw.ide.gui.core.mainwindow.console; import java.util.ArrayDeque; import java.util.Deque; @@ -14,8 +14,9 @@ import javafx.scene.control.ListCell; import javafx.scene.control.ListView; -import com.devonfw.ide.gui.FxHelper; -import com.devonfw.ide.gui.nls.NlsService; +import com.devonfw.ide.gui.core.context.GuiStateManager; +import com.devonfw.ide.gui.core.helper.FxHelper; +import com.devonfw.ide.gui.core.service.NlsService; import com.devonfw.tools.ide.log.IdeLogEntry; import com.devonfw.tools.ide.log.IdeLogLevel; @@ -59,10 +60,10 @@ private void initialize() { setupEventHandlers(); } - /// @param nlsService nlsService injection - public ConsoleController(NlsService nlsService) { - this.nlsService = nlsService; + public ConsoleController(GuiStateManager guiStateManager) { + + this.nlsService = guiStateManager.getNlsService(); } /** diff --git a/gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelView.java b/gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelView.java new file mode 100644 index 00000000..45dbd6af --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelView.java @@ -0,0 +1,60 @@ +package com.devonfw.ide.gui.core.mainwindow.navigation; + + +import javafx.fxml.FXML; +import javafx.scene.control.ComboBox; +import javafx.scene.layout.VBox; + +import com.devonfw.ide.gui.core.helper.FxHelper; +import com.devonfw.ide.gui.core.service.NlsService; + +public class NavigationPanelView extends VBox { + + @FXML + private ComboBox projects; + + @FXML + private ComboBox workspaces; + + @FXML + private ComboBox languages; + + private final NlsService nlsService; + + private final NavigationPanelViewModel viewModel; + + /** + * Creates the navigation panel and loads its FXML, resolving the default-locale bundle for the {@code %key} text. + */ + public NavigationPanelView(NavigationPanelViewModel viewModel, NlsService nlsService) { + super(); + + this.nlsService = nlsService; + this.viewModel = viewModel; + + FxHelper.loadFxml("NavigationPanel.fxml", nlsService, this); + } + + @FXML + private void initialize() { + this.projects.setItems(viewModel.getProjectsViewModel().getItems()); + this.projects.valueProperty().bindBidirectional(viewModel.getProjectsViewModel().selectedItemProperty()); + this.projects.getSelectionModel().selectedItemProperty().addListener((_, _, new_value) -> { + if (new_value != null) { + viewModel.populateWorkspaceComboBox(); + } + }); + + this.workspaces.setItems(viewModel.getWorkspacesViewModel().getItems()); + this.workspaces.valueProperty().bindBidirectional(viewModel.getWorkspacesViewModel().selectedItemProperty()); + this.workspaces.disableProperty().bind(viewModel.getProjectsViewModel().selectedItemProperty().isNull()); + this.workspaces.getSelectionModel().selectedItemProperty().addListener((_, _, new_value) -> { + if (new_value != null) { + viewModel.updateContext(); + } + }); + + this.languages.setItems(viewModel.getLanguagesViewModel().getItems()); + this.languages.valueProperty().bindBidirectional(viewModel.getLanguagesViewModel().selectedItemProperty()); + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelViewModel.java b/gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelViewModel.java new file mode 100644 index 00000000..0d8f71ca --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelViewModel.java @@ -0,0 +1,142 @@ +package com.devonfw.ide.gui.core.mainwindow.navigation; + +import java.io.FileNotFoundException; +import java.nio.file.NotDirectoryException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import javafx.scene.control.Alert.AlertType; + +import com.devonfw.ide.gui.core.context.GuiStateManager; +import com.devonfw.ide.gui.core.context.ProjectManager; +import com.devonfw.ide.gui.core.helper.combobox.ComboBoxViewModel; +import com.devonfw.ide.gui.core.modal.IdeDialog; +import com.devonfw.ide.gui.core.service.NlsService; + +public class NavigationPanelViewModel { + + private final ComboBoxViewModel projectsViewModel = new ComboBoxViewModel<>(); + + private final ComboBoxViewModel workspacesViewModel = new ComboBoxViewModel<>(); + + private final ComboBoxViewModel languagesViewModel = new ComboBoxViewModel<>(); + + private final Map languageMap = new LinkedHashMap<>(); + + private final GuiStateManager guiStateManager; + + private final ProjectManager projectManager; + + private final NlsService nlsService; + + public NavigationPanelViewModel(GuiStateManager guiStateManager) { + this.guiStateManager = guiStateManager; + this.projectManager = guiStateManager.getProjectManager(); + this.nlsService = guiStateManager.getNlsService(); + + initProjectsComboBox(); + initLanguageComboBox(); + } + + private void initProjectsComboBox() { + this.projectsViewModel.getItems().clear(); + this.projectsViewModel.getItems().addAll(this.projectManager.getProjectNames()); + } + + public void populateWorkspaceComboBox() { + + List loadedWorkspaces = loadWorkspaces(this.projectsViewModel.getSelectedItem().orElse(null)); + + this.workspacesViewModel.clearSelection(); + this.workspacesViewModel.getItems().clear(); + this.workspacesViewModel.getItems().addAll(loadedWorkspaces); + + // Default to the "main" workspace when it exists. + if (loadedWorkspaces.contains(GuiStateManager.DEFAULT_WORKSPACE)) { + this.workspacesViewModel.select(GuiStateManager.DEFAULT_WORKSPACE); + } + } + + private List loadWorkspaces(String project) { + + try { + return this.projectManager.getWorkspaceNames(project); + } catch (NotDirectoryException e) { + throw new RuntimeException(e); + } + } + + private void initLanguageComboBox() { + + this.languageMap.clear(); + this.languagesViewModel.getItems().clear(); + + for (Locale locale : this.nlsService.getAvailableLocales()) { + String displayName = this.nlsService.getLanguageDisplayName(locale); + this.languageMap.put(displayName, locale); + } + + this.languagesViewModel.getItems().addAll(this.languageMap.keySet()); + //initial value + this.languagesViewModel.select(resolveLanguageSelection(this.nlsService.getLocale())); + } + + private String resolveLanguageSelection(Locale currentLocale) { + + if (currentLocale == null) { + return this.languageMap.keySet().stream().findFirst().orElse(null); + } + + String languageMatch = null; + + for (Map.Entry entry : this.languageMap.entrySet()) { + Locale entryLocale = entry.getValue(); + // Exact language tag match takes priority + if (entryLocale.toLanguageTag().equalsIgnoreCase(currentLocale.toLanguageTag())) { + return entry.getKey(); + } + // Track language-only match as fallback + if (languageMatch == null) { + if (entryLocale.getLanguage().equalsIgnoreCase(currentLocale.getLanguage())) { + languageMatch = entry.getKey(); + } + } + } + + // Return language-only match if found, otherwise first available + return languageMatch != null ? languageMatch : this.languageMap.keySet().stream().findFirst().orElse(null); + } + + public void updateContext() { + + String project = this.projectsViewModel.getSelectedItem().orElse(null); + String workspace = this.workspacesViewModel.getSelectedItem().orElse(null); + + if (project == null || workspace == null) { + this.guiStateManager.workspaceSelectedProperty().set(false); + return; + } + + try { + this.guiStateManager.switchContext(project, workspace); + } catch (FileNotFoundException e) { + IdeDialog errorDialog = new IdeDialog(AlertType.ERROR, e.getMessage()); + errorDialog.showAndWait(); + } + } + + public ComboBoxViewModel getProjectsViewModel() { + return projectsViewModel; + } + + public ComboBoxViewModel getWorkspacesViewModel() { + return workspacesViewModel; + } + + public ComboBoxViewModel getLanguagesViewModel() { + return languagesViewModel; + } + +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/modal/IdeDialog.java b/gui/src/main/java/com/devonfw/ide/gui/core/modal/IdeDialog.java similarity index 96% rename from gui/src/main/java/com/devonfw/ide/gui/modal/IdeDialog.java rename to gui/src/main/java/com/devonfw/ide/gui/core/modal/IdeDialog.java index f152d30c..d4fc4235 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/modal/IdeDialog.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/modal/IdeDialog.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.modal; +package com.devonfw.ide.gui.core.modal; import javafx.scene.control.Alert; import javafx.scene.control.ButtonType; diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/ProgressBarTask.java b/gui/src/main/java/com/devonfw/ide/gui/core/progress/ProgressBarTask.java similarity index 91% rename from gui/src/main/java/com/devonfw/ide/gui/progress/ProgressBarTask.java rename to gui/src/main/java/com/devonfw/ide/gui/core/progress/ProgressBarTask.java index 7f2ac00b..754a268e 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/progress/ProgressBarTask.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/progress/ProgressBarTask.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.progress; +package com.devonfw.ide.gui.core.progress; import javafx.beans.property.BooleanProperty; import javafx.beans.property.LongProperty; @@ -10,8 +10,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.ide.gui.FxHelper; -import com.devonfw.ide.gui.context.TaskManager; +import com.devonfw.ide.gui.core.context.TaskManager; +import com.devonfw.ide.gui.core.helper.FxHelper; import com.devonfw.tools.ide.io.AbstractIdeProgressBar; /** @@ -78,8 +78,12 @@ protected void doStepBy(long stepSize, long currentProgress) { FxHelper.runFxSafe(() -> progressProperty.setValue(getCurrentProgress())); } + /** + * Exposed publicly (wider than the {@code protected} declaration in {@link AbstractIdeProgressBar}) so the task window tests, which live in a separate + * subpackage, can drive the progress bar to a precise position. + */ @Override - protected void doStepTo(long stepPosition) { + public void doStepTo(long stepPosition) { LOG.debug("Updating progress bar to {}", getCurrentProgress()); diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskOverviewWindow.java b/gui/src/main/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskOverviewWindow.java similarity index 92% rename from gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskOverviewWindow.java rename to gui/src/main/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskOverviewWindow.java index 39474f24..19dc6ced 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskOverviewWindow.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskOverviewWindow.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.progress.taskwindow; +package com.devonfw.ide.gui.core.progress.taskwindow; import java.io.IOException; @@ -12,8 +12,7 @@ import javafx.stage.Stage; import javafx.stage.StageStyle; -import com.devonfw.ide.gui.App; -import com.devonfw.ide.gui.context.TaskManager; +import com.devonfw.ide.gui.core.context.TaskManager; /** * This window is displayed when the user clicks on the task label in main-view.fxml. @@ -41,7 +40,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(getClass().getResource("task_overview_window.fxml")); fxmlLoader.setController(new TaskOverviewWindowController(taskManager)); Parent 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/core/progress/taskwindow/TaskOverviewWindowController.java similarity index 88% rename from gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskOverviewWindowController.java rename to gui/src/main/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskOverviewWindowController.java index 19776bf0..89bf320b 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskOverviewWindowController.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskOverviewWindowController.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.progress.taskwindow; +package com.devonfw.ide.gui.core.progress.taskwindow; import javafx.beans.Observable; import javafx.collections.FXCollections; @@ -6,8 +6,8 @@ import javafx.fxml.FXML; import javafx.scene.control.ListView; -import com.devonfw.ide.gui.context.TaskManager; -import com.devonfw.ide.gui.progress.ProgressBarTask; +import com.devonfw.ide.gui.core.context.TaskManager; +import com.devonfw.ide.gui.core.progress.ProgressBarTask; /** * Controller for the task overview window, which shows all currently running tasks and their progressbar. diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskWindowCellFactory.java b/gui/src/main/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskWindowCellFactory.java similarity index 96% rename from gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskWindowCellFactory.java rename to gui/src/main/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskWindowCellFactory.java index c7b943da..c39edd7d 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskWindowCellFactory.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskWindowCellFactory.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.progress.taskwindow; +package com.devonfw.ide.gui.core.progress.taskwindow; import javafx.application.Platform; import javafx.beans.binding.Bindings; @@ -16,7 +16,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.ide.gui.progress.ProgressBarTask; +import com.devonfw.ide.gui.core.progress.ProgressBarTask; /** * Cell factory for displaying a list of tasks in the {@link TaskOverviewWindow} diff --git a/gui/src/main/java/com/devonfw/ide/gui/core/service/CommandletService.java b/gui/src/main/java/com/devonfw/ide/gui/core/service/CommandletService.java new file mode 100644 index 00000000..2897bac6 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/core/service/CommandletService.java @@ -0,0 +1,121 @@ +package com.devonfw.ide.gui.core.service; + +import java.nio.file.Path; +import java.util.Objects; + +import javafx.application.Platform; +import javafx.concurrent.Task; +import javafx.scene.control.Alert.AlertType; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.ide.gui.core.context.GuiOutputListener; +import com.devonfw.ide.gui.core.context.GuiStateManager; +import com.devonfw.ide.gui.core.context.IdeGuiContext; +import com.devonfw.ide.gui.core.context.IdeGuiLogListener; +import com.devonfw.ide.gui.core.mainwindow.console.ConsoleController; +import com.devonfw.ide.gui.core.modal.IdeDialog; +import com.devonfw.ide.gui.core.progress.ProgressBarTask; +import com.devonfw.tools.ide.context.IdeStartContextImpl; +import com.devonfw.tools.ide.log.IdeLogLevel; + +/** + * Service to run a commandlet in the currently selected project/workspace context. + */ +public class CommandletService { + + private static final Logger LOG = LoggerFactory.getLogger(CommandletService.class); + + private final GuiStateManager guiStateManager; + private final ConsoleController consoleController; + + private IdeGuiLogListener guiLogListener; + private GuiOutputListener guiOutputListener; + + /** + * Optional action invoked before a commandlet is launched, e.g. to make the console pane visible. Defaults to a no-op. Part of the hack to make the Console + * auto-show when an IDE is launched, should be removed when Console is reworked. + */ + private Runnable preLaunchAction = () -> { + }; + + /** + * Creates the service. + * + * @param guiStateManager the app-wide selection and context holder. + */ + public CommandletService(GuiStateManager guiStateManager) { + this.guiStateManager = Objects.requireNonNull(guiStateManager); + this.consoleController = Objects.requireNonNull(guiStateManager.getConsoleController()); + this.guiLogListener = new IdeGuiLogListener(consoleController); + this.guiOutputListener = new GuiOutputListener(consoleController); + } + + /** + * Sets the action that is run before each commandlet launch. Part of the hack to make the Console auto-show when an IDE is launched, should be removed when + * Console is reworked. + * + * @param preLaunchAction the action to run before launching a commandlet (must not be null). + */ + public void setPreLaunchAction(Runnable preLaunchAction) { + this.preLaunchAction = Objects.requireNonNull(preLaunchAction); + } + + /** + * Runs the given commandlet in the currently selected project/workspace context. + * + * @param commandlet the commandlet to run. + */ + public void runCommandlet(String commandlet) { + + this.preLaunchAction.run(); + + Task commandletTask = runCommandletTask(commandlet); + + this.guiLogListener = new IdeGuiLogListener(consoleController); + this.guiOutputListener = new GuiOutputListener(consoleController); + + Thread commandletThread = new Thread(commandletTask); + commandletThread.setDaemon(true); + commandletThread.start(); + } + + private Task runCommandletTask(String commandlet) { + + try (ProgressBarTask task = (ProgressBarTask) this.guiStateManager.getCurrentContext() + .newProgressBarIndeterminate("Starting " + commandlet)) { + Task commandletTask = new Task<>() { + @Override + protected Void call() { + + try { + IdeStartContextImpl startContext = new IdeStartContextImpl(IdeLogLevel.INFO, guiLogListener); + Path workspacePath = guiStateManager.getCurrentContext().getWorkspacePath(); + IdeGuiContext context = new IdeGuiContext(startContext, workspacePath, CommandletService.this.guiStateManager.getTaskManager()); + + context.setOutputListener(guiOutputListener); + + LOG.info("Running commandlet {}", commandlet); + + context.getCommandletManager().getCommandlet(commandlet).run(); + + LOG.info("Commandlet {} completed successfully.", commandlet); + } catch (Exception e) { + LOG.error("Failed to run commandlet {}: {}", commandlet, e.getMessage(), e); + consoleController.appendOutput("[ERROR] Failed to launch " + commandlet + ": " + e.getMessage()); + } + return null; + } + }; + + commandletTask.setOnFailed(_ -> Platform.runLater(() -> { + task.close(); + new IdeDialog(AlertType.ERROR, "Error occurred while launching " + commandlet).showAndWait(); + })); + commandletTask.setOnSucceeded(_ -> Platform.runLater(task::close)); + return commandletTask; + } + } + +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/nls/NlsService.java b/gui/src/main/java/com/devonfw/ide/gui/core/service/NlsService.java similarity index 99% rename from gui/src/main/java/com/devonfw/ide/gui/nls/NlsService.java rename to gui/src/main/java/com/devonfw/ide/gui/core/service/NlsService.java index 0bf90649..227e2852 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/nls/NlsService.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/service/NlsService.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.nls; +package com.devonfw.ide.gui.core.service; import java.io.IOException; import java.net.URI; diff --git a/gui/src/main/java/com/devonfw/ide/gui/core/tab/TabComponent.java b/gui/src/main/java/com/devonfw/ide/gui/core/tab/TabComponent.java new file mode 100644 index 00000000..d1306393 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/core/tab/TabComponent.java @@ -0,0 +1,60 @@ +package com.devonfw.ide.gui.core.tab; + +import javafx.scene.control.Tab; + +import com.devonfw.ide.gui.core.context.GuiStateManager; +import com.devonfw.ide.gui.core.event.TabChangeEvent; +import com.devonfw.ide.gui.core.mainwindow.console.ConsoleController; +import com.devonfw.ide.gui.core.service.CommandletService; +import com.devonfw.ide.gui.core.service.NlsService; + +/** + * Base class for the tabs of the main window. Each tab combines a {@link TabViewModel} with a {@link TabView} that renders it. + */ +public abstract class TabComponent { + + protected final GuiStateManager guiStateManager; + protected final CommandletService commandletService; + protected final NlsService nlsService; + protected ConsoleController consoleController; + + protected Tab tab; + protected TabView view; + protected T viewModel; + + /** + * Creates the tab. + * + * @param guiStateManager the app-wide selection and context holder. + */ + public TabComponent(GuiStateManager guiStateManager) { + this.consoleController = guiStateManager.getConsoleController(); + this.commandletService = guiStateManager.getCommandletService(); + this.nlsService = guiStateManager.getNlsService(); + this.guiStateManager = guiStateManager; + } + + /** + * Creates the tab's view and its view model. + * + * @return the created view. + */ + public abstract TabView createTab(); + + public abstract TabChangeEvent createTabChangeEvent(); + + /** + * @return the localization key used as the title (and identity) of this tab. + */ + public abstract String getTabTitleKey(); + + /** + * @return the view + */ + public abstract TabView getView(); + + /** + * @return the view model + */ + public abstract T getViewModel(); +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/core/tab/TabView.java b/gui/src/main/java/com/devonfw/ide/gui/core/tab/TabView.java new file mode 100644 index 00000000..393e4cd8 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/core/tab/TabView.java @@ -0,0 +1,23 @@ +package com.devonfw.ide.gui.core.tab; + +import javafx.scene.control.ScrollPane; + +import com.devonfw.ide.gui.core.helper.FxHelper; +import com.devonfw.ide.gui.core.service.NlsService; + +/** + * Base class for the views of all tabs. + */ +public abstract class TabView extends ScrollPane { + + /** + * Loads this view's FXML file. + * + * @param fxmlName the FXML file name co-located with the concrete tab view (e.g. {@code "IdeLauncher.fxml"}). + * @param nlsService the localization service; its resource bundle resolves the {@code %key} text keys in the FXML. + */ + protected void loadFxml(String fxmlName, NlsService nlsService) { + FxHelper.loadFxml(fxmlName, nlsService, this); + } + +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/core/tab/TabViewModel.java b/gui/src/main/java/com/devonfw/ide/gui/core/tab/TabViewModel.java new file mode 100644 index 00000000..3ac96006 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/core/tab/TabViewModel.java @@ -0,0 +1,21 @@ +package com.devonfw.ide.gui.core.tab; + +import com.devonfw.ide.gui.core.context.GuiStateManager; + +/** + * Base for the view models of all tabs. + */ +public abstract class TabViewModel { + + protected final GuiStateManager guiStateManager; + + protected TabViewModel(GuiStateManager guiStateManager) { + this.guiStateManager = guiStateManager; + } + + /** + * @return the localization key used as the title of this tab. + */ + public abstract String getTabTitleKey(); + +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherTab.java b/gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherTab.java new file mode 100644 index 00000000..82b89e36 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherTab.java @@ -0,0 +1,58 @@ +package com.devonfw.ide.gui.feature.ide.launcher; + +import javafx.scene.control.Tab; + +import com.devonfw.ide.gui.core.context.GuiStateManager; +import com.devonfw.ide.gui.core.event.TabChangeEvent; +import com.devonfw.ide.gui.core.tab.TabComponent; +import com.devonfw.ide.gui.core.tab.TabView; + +/** + * Tab that lets the user launch the supported IDEs from the main window. + */ +public class IdeLauncherTab extends TabComponent { + + private final String tabTitleKey = "tab.ide_launcher"; + + /** + * Creates the IDE launcher tab. + * + * @param guiStateManager the app-wide selection and context holder. + */ + public IdeLauncherTab(GuiStateManager guiStateManager) { + super(guiStateManager); + } + + @Override + public TabView createTab() { + this.viewModel = new IdeLauncherViewModel(this.guiStateManager); + this.view = new IdeLauncherView(this.viewModel, this.nlsService); + this.tab = new Tab(this.nlsService.get(tabTitleKey), this.view); + this.tab.setUserData(tabTitleKey); + this.tab.setClosable(true); + + return this.view; + } + + @Override + public TabChangeEvent createTabChangeEvent() { + createTab(); + return new TabChangeEvent(this.tab); + } + + @Override + public String getTabTitleKey() { + return this.tabTitleKey; + } + + @Override + public TabView getView() { + return view; + } + + @Override + public IdeLauncherViewModel getViewModel() { + return viewModel; + } + +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherView.java b/gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherView.java new file mode 100644 index 00000000..b56ed470 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherView.java @@ -0,0 +1,52 @@ +package com.devonfw.ide.gui.feature.ide.launcher; + +import javafx.fxml.FXML; +import javafx.scene.control.Button; + +import com.devonfw.ide.gui.core.service.NlsService; +import com.devonfw.ide.gui.core.tab.TabView; + +/** + * View for the IDE launcher tab. + */ +public class IdeLauncherView extends TabView { + + @FXML + private Button androidStudioOpen; + + @FXML + private Button eclipseOpen; + + @FXML + private Button intellijOpen; + + @FXML + private Button vsCodeOpen; + + private final IdeLauncherViewModel viewModel; + + /** + * Creates the launcher view and loads its FXML. + * + * @param viewModel the view model of the launcher tab. + * @param nlsService the localization service. + */ + public IdeLauncherView(IdeLauncherViewModel viewModel, NlsService nlsService) { + this.viewModel = viewModel; + loadFxml("IdeLauncher.fxml", nlsService); + } + + @FXML + private void initialize() { + + this.androidStudioOpen.disableProperty().bind(this.viewModel.isIdeButtonsDisabledProperty()); + this.androidStudioOpen.setOnAction(_ -> this.viewModel.runCommandlet("android-studio")); + this.eclipseOpen.disableProperty().bind(this.viewModel.isIdeButtonsDisabledProperty()); + this.eclipseOpen.setOnAction(_ -> this.viewModel.runCommandlet("eclipse")); + this.intellijOpen.disableProperty().bind(this.viewModel.isIdeButtonsDisabledProperty()); + this.intellijOpen.setOnAction(_ -> this.viewModel.runCommandlet("intellij")); + this.vsCodeOpen.disableProperty().bind(this.viewModel.isIdeButtonsDisabledProperty()); + this.vsCodeOpen.setOnAction(_ -> this.viewModel.runCommandlet("vscode")); + } + +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherViewModel.java b/gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherViewModel.java new file mode 100644 index 00000000..4b35f9b8 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherViewModel.java @@ -0,0 +1,49 @@ +package com.devonfw.ide.gui.feature.ide.launcher; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; + +import com.devonfw.ide.gui.core.context.GuiStateManager; +import com.devonfw.ide.gui.core.service.CommandletService; +import com.devonfw.ide.gui.core.tab.TabViewModel; + +/** + * View model of the IDE launcher tab. It exposes the state that drives the launcher buttons, which stay disabled until a project/workspace is selected. + */ +public class IdeLauncherViewModel extends TabViewModel { + + private final CommandletService commandletService; + + private final BooleanProperty isIdeButtonsDisabledProperty = new SimpleBooleanProperty(); + + /** + * Creates the view model. + * + * @param guiStateManager the app-wide selection and context holder. + */ + public IdeLauncherViewModel(GuiStateManager guiStateManager) { + super(guiStateManager); + this.commandletService = guiStateManager.getCommandletService(); + + this.isIdeButtonsDisabledProperty.bind(this.guiStateManager.workspaceSelectedProperty().not()); + } + + @Override + public String getTabTitleKey() { + return "tab.ide_launcher"; + } + + /** + * Runs the given IDE commandlet in the currently selected project/workspace context. + * + * @param commandlet the commandlet (IDE) to start. + */ + public void runCommandlet(String commandlet) { + this.commandletService.runCommandlet(commandlet); + } + + public BooleanProperty isIdeButtonsDisabledProperty() { + return isIdeButtonsDisabledProperty; + } + +} diff --git a/gui/src/main/resources/com/devonfw/ide/gui/core/mainwindow/MainWindowView.fxml b/gui/src/main/resources/com/devonfw/ide/gui/core/mainwindow/MainWindowView.fxml new file mode 100644 index 00000000..b3455413 --- /dev/null +++ b/gui/src/main/resources/com/devonfw/ide/gui/core/mainwindow/MainWindowView.fxml @@ -0,0 +1,54 @@ + + + + + + + + +

+ +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+ diff --git a/gui/src/main/resources/com/devonfw/ide/gui/console/console.fxml b/gui/src/main/resources/com/devonfw/ide/gui/core/mainwindow/console/console.fxml similarity index 77% rename from gui/src/main/resources/com/devonfw/ide/gui/console/console.fxml rename to gui/src/main/resources/com/devonfw/ide/gui/core/mainwindow/console/console.fxml index 10c2c7f8..f0afc0ec 100644 --- a/gui/src/main/resources/com/devonfw/ide/gui/console/console.fxml +++ b/gui/src/main/resources/com/devonfw/ide/gui/core/mainwindow/console/console.fxml @@ -1,22 +1,15 @@ - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gui/src/main/resources/com/devonfw/ide/gui/task_overview_window.fxml b/gui/src/main/resources/com/devonfw/ide/gui/core/progress/taskwindow/task_overview_window.fxml similarity index 100% rename from gui/src/main/resources/com/devonfw/ide/gui/task_overview_window.fxml rename to gui/src/main/resources/com/devonfw/ide/gui/core/progress/taskwindow/task_overview_window.fxml diff --git a/gui/src/main/resources/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncher.fxml b/gui/src/main/resources/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncher.fxml new file mode 100644 index 00000000..cdb87c11 --- /dev/null +++ b/gui/src/main/resources/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncher.fxml @@ -0,0 +1,181 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 deleted file mode 100644 index 0ba39ccd..00000000 --- a/gui/src/main/resources/com/devonfw/ide/gui/main-view.fxml +++ /dev/null @@ -1,335 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - -
-
-
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 5e4d2ca2..df411eb5 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,4 +1,4 @@ -VBox { +.navigationPanel { -fx-background-color: #18151e; } diff --git a/gui/src/main/resources/nls/messages.properties b/gui/src/main/resources/nls/messages.properties index a92dfae8..e58115e9 100644 --- a/gui/src/main/resources/nls/messages.properties +++ b/gui/src/main/resources/nls/messages.properties @@ -15,6 +15,8 @@ language=Language chooseLanguage=Choose language # Status Bar ideasy_ready_msg=IDEasy is ready. +# Tabs +tab.ide_launcher=IDEs # Console console_togglebutton_tooltip=Toggle console console_title=Console diff --git a/gui/src/main/resources/nls/messages_de.properties b/gui/src/main/resources/nls/messages_de.properties index 05b5abeb..61c9b65a 100644 --- a/gui/src/main/resources/nls/messages_de.properties +++ b/gui/src/main/resources/nls/messages_de.properties @@ -15,6 +15,8 @@ language=Sprache chooseLanguage=Sprache wählen # Status Bar ideasy_ready_msg=IDEasy ist bereit +# Tabs +tab.ide_launcher=IDEs # Console console_togglebutton_tooltip=Konsole öffnen/schließen console_title=Konsole diff --git a/gui/src/test/java/com/devonfw/ide/gui/AppBaseTest.java b/gui/src/test/java/com/devonfw/ide/gui/AppBaseTest.java index 8a16c513..69e613c9 100644 --- a/gui/src/test/java/com/devonfw/ide/gui/AppBaseTest.java +++ b/gui/src/test/java/com/devonfw/ide/gui/AppBaseTest.java @@ -4,13 +4,10 @@ import static org.testfx.util.WaitForAsyncUtils.waitForFxEvents; import java.io.IOException; -import java.net.URL; import java.nio.file.Path; import java.util.Arrays; import java.util.Locale; -import javafx.fxml.FXMLLoader; -import javafx.scene.Parent; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.ComboBox; @@ -30,12 +27,13 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.ide.gui.console.ConsoleController; -import com.devonfw.ide.gui.context.GuiStateManager; -import com.devonfw.ide.gui.context.TaskManager; -import com.devonfw.ide.gui.nls.NlsService; -import com.devonfw.ide.gui.progress.ProgressBarTask; -import com.devonfw.ide.gui.progress.taskwindow.TaskOverviewWindow; +import com.devonfw.ide.gui.core.context.GuiStateManager; +import com.devonfw.ide.gui.core.context.TaskManager; +import com.devonfw.ide.gui.core.helper.FxHelper; +import com.devonfw.ide.gui.core.mainwindow.MainWindowView; +import com.devonfw.ide.gui.core.mainwindow.MainWindowViewModel; +import com.devonfw.ide.gui.core.progress.ProgressBarTask; +import com.devonfw.ide.gui.core.progress.taskwindow.TaskOverviewWindow; /** * Basic UI Test for the main screen @@ -44,6 +42,11 @@ public class AppBaseTest extends HeadlessApplicationTest { private static final Logger LOGGER = LoggerFactory.getLogger(AppBaseTest.class); + /** Scene size for the test window. The console SplitPane needs real space, or its divider stays pinned near 1.0. */ + private static final double SCENE_WIDTH = 1280; + + private static final double SCENE_HEIGHT = 800; + private Button androidStudioOpen, eclipseOpen, intellijOpen, vsCodeOpen; private ToggleButton consolePaneToggleButton; private ComboBox selectedProject, selectedWorkspace; @@ -54,47 +57,42 @@ public class AppBaseTest extends HeadlessApplicationTest { @TempDir private static Path mockIdeRoot; - private static final TaskManager taskManager = new TaskManager(); - private static GuiStateManager guiStateManager; + /** + * Both are recreated for every test in {@link #start(Stage)}: a shared GuiStateManager leaks the project selection into the next test, and a shared + * TaskManager accumulates a status-bar listener per view model, so discarded windows keep reacting to task changes. + */ + private TaskManager taskManager; + + private GuiStateManager guiStateManager; + + private MainWindowViewModel viewModel; @Override - public void start(Stage stage) throws IOException { - - NlsService nlsService = new NlsService(Locale.ENGLISH); - - URL mainViewUrl = getClass().getResource("main-view.fxml"); - assertThat(mainViewUrl).as("Cannot resolve main UI FXML resource!").isNotNull(); - - FXMLLoader fxmlLoader = new FXMLLoader(mainViewUrl); - MainController mainController = new MainController(mockIdeRoot.toString(), guiStateManager, nlsService); - fxmlLoader.setControllerFactory(clazz -> { - if (clazz == ConsoleController.class) { - return new ConsoleController(nlsService); - } else if (clazz == MainController.class) { - return mainController; - } - return null; - }); - fxmlLoader.setResources(nlsService.getResourceBundle()); - Parent root = fxmlLoader.load(); - stage.setScene(new Scene(root)); + public void start(Stage stage) { + + this.guiStateManager = new GuiStateManager(mockIdeRoot.toString()); + this.guiStateManager.getNlsService().setLocale(Locale.ENGLISH); + this.taskManager = guiStateManager.getTaskManager(); + this.viewModel = new MainWindowViewModel(guiStateManager); + MainWindowView mainWindow = new MainWindowView(this.viewModel, guiStateManager); + stage.setScene(new Scene(mainWindow, SCENE_WIDTH, SCENE_HEIGHT)); stage.requestFocus(); //sometimes needed for headless setup to work stage.show(); - androidStudioOpen = FxHelper.lookup(root, "#androidStudioOpen"); - eclipseOpen = FxHelper.lookup(root, "#eclipseOpen"); - intellijOpen = FxHelper.lookup(root, "#intellijOpen"); - vsCodeOpen = FxHelper.lookup(root, "#vsCodeOpen"); - selectedProject = FxHelper.lookup(root, "#selectedProject"); - selectedWorkspace = FxHelper.lookup(root, "#selectedWorkspace"); - consolePaneToggleButton = FxHelper.lookup(root, "#consolePaneToggleButton"); - centerSplitPane = FxHelper.lookup(root, "#centerSplitPane"); - statusText = FxHelper.lookup(root, "#statusLabel"); - taskProgressBar = FxHelper.lookup(root, "#statusProgressBar"); + androidStudioOpen = FxHelper.lookup(mainWindow, "#androidStudioOpen"); + eclipseOpen = FxHelper.lookup(mainWindow, "#eclipseOpen"); + intellijOpen = FxHelper.lookup(mainWindow, "#intellijOpen"); + vsCodeOpen = FxHelper.lookup(mainWindow, "#vsCodeOpen"); + selectedProject = FxHelper.lookup(mainWindow, "#projects"); + selectedWorkspace = FxHelper.lookup(mainWindow, "#workspaces"); + consolePaneToggleButton = FxHelper.lookup(mainWindow, "#consolePaneToggleButton"); + centerSplitPane = FxHelper.lookup(mainWindow, "#centerSplitPane"); + statusText = FxHelper.lookup(mainWindow, "#statusLabel"); + taskProgressBar = FxHelper.lookup(mainWindow, "#statusProgressBar"); } /** - * Generate temporary project directories to be able to test on any device (including GitHub CI). This is required for the {@link MainController} to work in + * Generate temporary project directories to be able to test on any device (including GitHub CI). This is required for the {@link MainWindowView} to work in * the test context. Generates a structure like this: /project-[0..6]/workspaces/main */ @BeforeAll @@ -104,9 +102,6 @@ public static void generateProjectFolderStructure() throws IOException { FakeProjectFolderStructureHelper.createFakeProjectFolderStructure(mockIdeRoot); LOGGER.debug("project folders: {}", Arrays.toString(mockIdeRoot.toFile().list())); - guiStateManager = new GuiStateManager(taskManager, mockIdeRoot.toString()); - //We set the project root directory to the temporary directory before all tests so that the IDE can find the projects in the test. - guiStateManager.switchContext("project-1", "main"); } @BeforeEach @@ -244,8 +239,61 @@ protected void testStatusTextOpensTaskOverviewWindow() { .isTrue(); } + /** + * The status label now carries a permanently installed click handler that is gated on the view model's clickable state, where it previously had the handler + * attached only while more than one task was running. This pins that gate. + */ + @Test + protected void testStatusTextDoesNotOpenTaskOverviewWindowForSingleTask() { + + // The window is a JVM-wide singleton, so an earlier test may have left it showing. getInstance builds the Stage, so it has to run on the FX thread. + interact(() -> TaskOverviewWindow.getInstance(this.taskManager).getStage().hide()); + + this.taskManager.addTask(new ProgressBarTask(this.taskManager, "task-1", "Test Task")); + waitForFxEvents(); + + interact(() -> statusText.fireEvent( + new MouseEvent(MouseEvent.MOUSE_CLICKED, 0, 0, 0, 0, null, 1, false, false, false, false, false, false, false, false, false, false, null))); + + assertThat(TaskOverviewWindow.getInstance(this.taskManager).getStage().isShowing()) + .as("Task overview window should not open while only a single task is running").isFalse(); + } + //===Console panel tests=== + /** + * The pre-launch action registered on the TabFactory opens the console by calling {@code setConsoleVisible(true)} on the view model. Launching a commandlet + * for real would install an IDE, so this covers the mechanism that action drives rather than the launch itself. + */ + @Test + void testConsoleOpensWhenRequestedThroughTheViewModel() { + + Divider mainPanelDivider = centerSplitPane.getDividers().getFirst(); + assertThat(mainPanelDivider.getPosition()).as("The console should start collapsed").isGreaterThan(0.99); + + interact(() -> this.viewModel.setConsoleVisible(true)); + waitForFxEvents(); + + assertThat(mainPanelDivider.getPosition()).as("Console panel should be extended when the view model requests it").isEqualTo(0.75, Offset.offset(0.01)); + assertThat(consolePaneToggleButton.isSelected()).as("The toggle button should follow the view model").isTrue(); + } + + /** + * Dragging the divider must not be fought by the view writing the position back. Crossing the visible threshold used to snap the divider straight to 0.75. + */ + @Test + void testDraggingDividerDoesNotSnapTheConsoleOpen() { + + Divider mainPanelDivider = centerSplitPane.getDividers().getFirst(); + + // Just past the point where the console counts as visible, as if the user were dragging it open. + interact(() -> mainPanelDivider.setPosition(0.985)); + waitForFxEvents(); + + assertThat(mainPanelDivider.getPosition()).as("The divider must stay where the drag put it").isEqualTo(0.985, Offset.offset(0.001)); + assertThat(consolePaneToggleButton.isSelected()).as("The console counts as visible past the threshold").isTrue(); + } + @Test void testConsoleToggleButton() { diff --git a/gui/src/test/java/com/devonfw/ide/gui/context/GuiStateManagerTest.java b/gui/src/test/java/com/devonfw/ide/gui/core/context/GuiStateManagerTest.java similarity index 91% rename from gui/src/test/java/com/devonfw/ide/gui/context/GuiStateManagerTest.java rename to gui/src/test/java/com/devonfw/ide/gui/core/context/GuiStateManagerTest.java index dcfb3312..98dee7fb 100644 --- a/gui/src/test/java/com/devonfw/ide/gui/context/GuiStateManagerTest.java +++ b/gui/src/test/java/com/devonfw/ide/gui/core/context/GuiStateManagerTest.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.context; +package com.devonfw.ide.gui.core.context; import java.io.FileNotFoundException; import java.io.IOException; @@ -26,7 +26,6 @@ public class GuiStateManagerTest extends AbstractIdeContextTest { private static GuiStateManager guiStateManager; private static ProjectManager projectManager; - private TaskManager taskManager; @BeforeAll static void setup() { @@ -38,8 +37,7 @@ static void setup() { @BeforeEach void reset() { - TaskManager taskManager = new TaskManager(); - guiStateManager = new GuiStateManager(taskManager, context.getIdeRoot().toString()); + guiStateManager = new GuiStateManager(context.getIdeRoot().toString()); projectManager = guiStateManager.getProjectManager(); } @@ -47,7 +45,7 @@ void reset() { void testThrowsIfIdeRootDoesNotExist() { try { - new GuiStateManager(taskManager, "nonExistingIdeRoot"); + new GuiStateManager("nonExistingIdeRoot"); fail("IllegalArgumentException expected"); } catch (IllegalArgumentException e) { assertThat(e.getMessage()).contains("Root directory does not exist"); diff --git a/gui/src/test/java/com/devonfw/ide/gui/context/ProjectManagerTest.java b/gui/src/test/java/com/devonfw/ide/gui/core/context/ProjectManagerTest.java similarity index 99% rename from gui/src/test/java/com/devonfw/ide/gui/context/ProjectManagerTest.java rename to gui/src/test/java/com/devonfw/ide/gui/core/context/ProjectManagerTest.java index ee7161a5..e3a18aa1 100644 --- a/gui/src/test/java/com/devonfw/ide/gui/context/ProjectManagerTest.java +++ b/gui/src/test/java/com/devonfw/ide/gui/core/context/ProjectManagerTest.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.context; +package com.devonfw.ide.gui.core.context; import java.io.IOException; import java.nio.file.Files; diff --git a/gui/src/test/java/com/devonfw/ide/gui/core/event/GuiEventBusTest.java b/gui/src/test/java/com/devonfw/ide/gui/core/event/GuiEventBusTest.java new file mode 100644 index 00000000..c2eeed8d --- /dev/null +++ b/gui/src/test/java/com/devonfw/ide/gui/core/event/GuiEventBusTest.java @@ -0,0 +1,36 @@ +package com.devonfw.ide.gui.core.event; + +import java.util.concurrent.atomic.AtomicReference; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Pins the threading contract of the {@link GuiEventBus}: it dispatches events synchronously on the thread that sends them and performs no JavaFX-thread + * marshalling, so non-GUI threads (e.g. the CLI/worker threads that will route logs to the GUI console) can publish without blocking on or hopping to the FX + * thread. + */ +public class GuiEventBusTest extends Assertions { + + private record SomeEvent() { + + } + + /** + * An event sent from a plain (non-FX) thread must reach its listener synchronously, on that very same thread. + */ + @Test + public void testEventIsDispatchedSynchronouslyOnTheSendingThread() { + + GuiEventBus eventBus = new GuiEventBus(); + AtomicReference listenerThread = new AtomicReference<>(); + eventBus.addListener(SomeEvent.class, event -> listenerThread.set(Thread.currentThread())); + + Thread sender = Thread.currentThread(); + eventBus.sendEvent(new SomeEvent()); + + assertThat(listenerThread.get()) + .as("The listener must have run before sendEvent returned, on the thread that sent the event") + .isSameAs(sender); + } +} diff --git a/gui/src/test/java/com/devonfw/ide/gui/core/factory/TabFactoryTest.java b/gui/src/test/java/com/devonfw/ide/gui/core/factory/TabFactoryTest.java new file mode 100644 index 00000000..2eb57099 --- /dev/null +++ b/gui/src/test/java/com/devonfw/ide/gui/core/factory/TabFactoryTest.java @@ -0,0 +1,85 @@ +package com.devonfw.ide.gui.core.factory; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import javafx.scene.Group; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Tab; +import javafx.stage.Stage; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.devonfw.ide.gui.HeadlessApplicationTest; +import com.devonfw.ide.gui.core.context.GuiStateManager; +import com.devonfw.ide.gui.core.event.GuiEventBus; +import com.devonfw.ide.gui.core.event.TabChangeEvent; + +/** + * Tests for {@link TabFactory}. < + */ +class TabFactoryTest extends HeadlessApplicationTest { + + @TempDir + Path ideRoot; + + private static final String LAUNCHER_TITLE_KEY = "tab.ide_launcher"; + + private List emittedTabs; + + @Override + public void start(Stage stage) { + stage.setScene(new Scene(new Group())); + stage.show(); + } + + /** + * Builds a {@link TabFactory} wired to a bus with a capturing listener, opens the IDE launcher tab {@code times} times and captures the emitted tabs. + */ + private void openLauncherTab(int times) { + interact(() -> { + GuiStateManager guiStateManager = new GuiStateManager(this.ideRoot.toString()); + GuiEventBus eventBus = guiStateManager.getEventBus(); + this.emittedTabs = new ArrayList<>(); + eventBus.addListener(TabChangeEvent.class, event -> this.emittedTabs.add(event.tab())); + TabFactory factory = guiStateManager.getTabFactory(); + + for (int i = 0; i < times; i++) { + factory.openLauncherTab(); + } + }); + } + + /** + * The opened tab must carry its NLS key as its stable identity, kept distinct from its rendered (translated) title, so de-duplication does not depend on the + * rendered title. + */ + @Test + void testOpenStoresTheTitleKeyAsTheTabIdentity() { + openLauncherTab(1); + + Tab tab = this.emittedTabs.get(0); + assertThat(tab.getUserData()).as("The tab must be identifiable by its NLS key, not its translated title").isEqualTo(LAUNCHER_TITLE_KEY); + assertThat(tab.getText()).as("The visible title must be the localized text, not the raw key").isNotEqualTo(LAUNCHER_TITLE_KEY); + } + + /** + * Each open must publish a {@link TabChangeEvent} carrying a fully built tab (with its view as content and closable enabled). + */ + @Test + void testOpenPublishesAKeyEventPerOpenWithABuiltTab() { + openLauncherTab(2); + + assertThat(this.emittedTabs).as("The factory must publish a tab for each open").hasSize(2); + for (Tab tab : this.emittedTabs) { + assertThat(tab.getContent()).as("The published tab must carry its view as content").isInstanceOf(Node.class); + assertThat(tab.isClosable()).as("The published launcher tab must be closable").isTrue(); + } + } + +} diff --git a/gui/src/test/java/com/devonfw/ide/gui/console/ConsolePanelTest.java b/gui/src/test/java/com/devonfw/ide/gui/core/mainwindow/console/ConsolePanelTest.java similarity index 86% rename from gui/src/test/java/com/devonfw/ide/gui/console/ConsolePanelTest.java rename to gui/src/test/java/com/devonfw/ide/gui/core/mainwindow/console/ConsolePanelTest.java index aff1c167..dd5bd1cb 100644 --- a/gui/src/test/java/com/devonfw/ide/gui/console/ConsolePanelTest.java +++ b/gui/src/test/java/com/devonfw/ide/gui/core/mainwindow/console/ConsolePanelTest.java @@ -1,12 +1,12 @@ -package com.devonfw.ide.gui.console; +package com.devonfw.ide.gui.core.mainwindow.console; import static org.assertj.core.api.Assertions.assertThat; import static org.testfx.util.WaitForAsyncUtils.waitForFxEvents; import java.io.IOException; import java.net.URL; +import java.nio.file.Path; import java.util.List; -import java.util.Locale; import javafx.application.Platform; import javafx.fxml.FXMLLoader; @@ -15,37 +15,38 @@ import javafx.stage.Stage; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import com.devonfw.ide.gui.HeadlessApplicationTest; -import com.devonfw.ide.gui.nls.NlsService; +import com.devonfw.ide.gui.core.context.GuiStateManager; +import com.devonfw.ide.gui.core.service.NlsService; import com.devonfw.tools.ide.log.IdeLogLevel; class ConsolePanelTest extends HeadlessApplicationTest { private ConsoleController consoleController; + @TempDir + private Path mockIdeRoot; + @Override public void start(Stage stage) throws IOException { URL consoleViewUrl = getClass().getResource("console.fxml"); assertThat(consoleViewUrl).as("Cannot resolve console UI FXML resource!").isNotNull(); - NlsService nlsService = new NlsService(Locale.ENGLISH); + GuiStateManager guiStateManager = new GuiStateManager(this.mockIdeRoot.toString()); + this.consoleController = guiStateManager.getConsoleController(); + + NlsService nlsService = guiStateManager.getNlsService(); FXMLLoader fxmlLoader = new FXMLLoader(consoleViewUrl); fxmlLoader.setResources(nlsService.getResourceBundle()); - fxmlLoader.setControllerFactory(clazz -> { - if (clazz == ConsoleController.class) { - return new ConsoleController(nlsService); - } - return null; - }); + fxmlLoader.setControllerFactory(clazz -> clazz == ConsoleController.class ? this.consoleController : null); Parent root = fxmlLoader.load(); stage.setScene(new Scene(root)); stage.requestFocus(); // sometimes needed for headless setup to work stage.show(); - - consoleController = fxmlLoader.getController(); } /** diff --git a/gui/src/test/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelViewModelTest.java b/gui/src/test/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelViewModelTest.java new file mode 100644 index 00000000..1b6161c0 --- /dev/null +++ b/gui/src/test/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelViewModelTest.java @@ -0,0 +1,71 @@ +package com.devonfw.ide.gui.core.mainwindow.navigation; + +import java.io.IOException; +import java.nio.file.Path; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.devonfw.ide.gui.FakeProjectFolderStructureHelper; +import com.devonfw.ide.gui.core.context.GuiStateManager; + +/** + * Tests for {@link NavigationPanelViewModel}. + *

+ * These run without starting the JavaFX toolkit. The project→workspace→context orchestration is driven by the view (see {@link NavigationPanelView}), so these + * tests exercise the view model's own public methods the way the view calls them. + */ +public class NavigationPanelViewModelTest extends Assertions { + + private static final String PROJECT = "project-1"; + + private static final String OTHER_PROJECT = "project-2"; + + private static final String DEFAULT_WORKSPACE = "main"; + + @TempDir + private Path mockIdeRoot; + + private GuiStateManager guiStateManager; + + private NavigationPanelViewModel viewModel; + + @BeforeEach + void setUp() throws IOException { + + FakeProjectFolderStructureHelper.createFakeProjectFolderStructure(this.mockIdeRoot); + + this.guiStateManager = new GuiStateManager(this.mockIdeRoot.toString()); + this.viewModel = new NavigationPanelViewModel(this.guiStateManager); + } + + /** + * Populating the workspaces for a selected project has to include {@value #DEFAULT_WORKSPACE} and preselect it. + */ + @Test + void testPopulatingWorkspacesPreselectsMainWorkspace() { + + this.viewModel.getProjectsViewModel().select(PROJECT); + this.viewModel.populateWorkspaceComboBox(); + + assertThat(this.viewModel.getWorkspacesViewModel().getItems()).contains(DEFAULT_WORKSPACE); + assertThat(this.viewModel.getWorkspacesViewModel().getSelectedItem()).contains(DEFAULT_WORKSPACE); + } + + /** + * Updating the context for a selected project/workspace has to switch the {@link GuiStateManager} to the corresponding working directory. + */ + @Test + void testUpdatingContextSwitchesToTheSelectedProjectAndWorkspace() { + + this.viewModel.getProjectsViewModel().select(OTHER_PROJECT); + this.viewModel.populateWorkspaceComboBox(); + this.viewModel.updateContext(); + + assertThat(this.guiStateManager.isWorkspaceSelected()).isTrue(); + assertThat(this.guiStateManager.getCurrentContext().getCwd()).endsWith(Path.of(OTHER_PROJECT, "workspaces", DEFAULT_WORKSPACE)); + } + +} diff --git a/gui/src/test/java/com/devonfw/ide/gui/progress/TaskManagerTest.java b/gui/src/test/java/com/devonfw/ide/gui/core/progress/TaskManagerTest.java similarity index 95% rename from gui/src/test/java/com/devonfw/ide/gui/progress/TaskManagerTest.java rename to gui/src/test/java/com/devonfw/ide/gui/core/progress/TaskManagerTest.java index 01c13ac1..b7094fd9 100644 --- a/gui/src/test/java/com/devonfw/ide/gui/progress/TaskManagerTest.java +++ b/gui/src/test/java/com/devonfw/ide/gui/core/progress/TaskManagerTest.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.progress; +package com.devonfw.ide.gui.core.progress; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -8,7 +8,7 @@ import org.junit.jupiter.api.Test; import com.devonfw.ide.gui.HeadlessApplicationTest; -import com.devonfw.ide.gui.context.TaskManager; +import com.devonfw.ide.gui.core.context.TaskManager; /** * Tests for the {@link TaskManager} class. We extend HeadlessApplicationTest because all TaskManager mutations run on the JavaFX Application Thread via diff --git a/gui/src/test/java/com/devonfw/ide/gui/progress/TaskWindowTest.java b/gui/src/test/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskWindowTest.java similarity index 93% rename from gui/src/test/java/com/devonfw/ide/gui/progress/TaskWindowTest.java rename to gui/src/test/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskWindowTest.java index 533c5687..86c149f3 100644 --- a/gui/src/test/java/com/devonfw/ide/gui/progress/TaskWindowTest.java +++ b/gui/src/test/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskWindowTest.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.progress; +package com.devonfw.ide.gui.core.progress.taskwindow; import static org.assertj.core.api.Assertions.assertThat; import static org.testfx.util.WaitForAsyncUtils.waitForFxEvents; @@ -17,12 +17,10 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import com.devonfw.ide.gui.App; -import com.devonfw.ide.gui.FxHelper; import com.devonfw.ide.gui.HeadlessApplicationTest; -import com.devonfw.ide.gui.context.TaskManager; -import com.devonfw.ide.gui.progress.taskwindow.TaskOverviewWindow; -import com.devonfw.ide.gui.progress.taskwindow.TaskOverviewWindowController; +import com.devonfw.ide.gui.core.context.TaskManager; +import com.devonfw.ide.gui.core.helper.FxHelper; +import com.devonfw.ide.gui.core.progress.ProgressBarTask; /** * Tests for the TaskOverviewWindow. We check whether the window is displayed correctly and whether it properly reacts to changes in the TaskManager. @@ -35,7 +33,7 @@ public class TaskWindowTest extends HeadlessApplicationTest { @Override public void start(Stage stage) throws Exception { - URL taskOverviewWindowUrl = App.class.getResource("task_overview_window.fxml"); + URL taskOverviewWindowUrl = TaskOverviewWindowController.class.getResource("task_overview_window.fxml"); assertThat(taskOverviewWindowUrl).as("Cannot resolve task overview window FXML resource!").isNotNull(); FXMLLoader fxmlLoader = new FXMLLoader(taskOverviewWindowUrl); diff --git a/gui/src/test/java/com/devonfw/ide/gui/nls/NlsServiceTest.java b/gui/src/test/java/com/devonfw/ide/gui/core/service/NlsServiceTest.java similarity index 99% rename from gui/src/test/java/com/devonfw/ide/gui/nls/NlsServiceTest.java rename to gui/src/test/java/com/devonfw/ide/gui/core/service/NlsServiceTest.java index f187dbf5..3385dcdf 100644 --- a/gui/src/test/java/com/devonfw/ide/gui/nls/NlsServiceTest.java +++ b/gui/src/test/java/com/devonfw/ide/gui/core/service/NlsServiceTest.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.nls; +package com.devonfw.ide.gui.core.service; import static org.junit.jupiter.params.provider.Arguments.arguments; diff --git a/pom.xml b/pom.xml index 6a95ab32..b6daeff0 100644 --- a/pom.xml +++ b/pom.xml @@ -26,6 +26,7 @@ 2.11.0 3.11.0 25 + 0.9.13 4.0.18 0.10.6 3.8.0 @@ -171,6 +172,11 @@ javafx-controls ${javafx.version} + + io.github.m-m-m + mmm-event + ${mmm.version} + From d2f908289feec770d2d68ff1f18b4e37867b3787 Mon Sep 17 00:00:00 2001 From: Franck Majeste Silatsa <105231573+majesteSil@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:46:56 +0200 Subject: [PATCH 34/66] #2442: make windows-installer a maven module to align with macos-installer (#2444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jörg Hohwiller --- .github/workflows/nightly-build.yml | 2 +- .github/workflows/release.yml | 2 +- .gitignore | 5 +++ pom.xml | 1 + windows-installer/pom.xml | 49 +++++++++++++++++++++++++++++ 5 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 windows-installer/pom.xml diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 6a239bbc..bac40fde 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -145,4 +145,4 @@ jobs: SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} RELEASE_VERSION: ${{ needs.resolve-version.outputs.snapshot_version }} - run: mvn --settings .mvn/settings.xml -DskipTests=true -Darchetype.test.skip=true -Dgpg.skip=true -Dstyle.color=always -B -ntp -pl -:ide-macos-installer -Passembly,msi,pkg,deploy deploy + run: mvn --settings .mvn/settings.xml -DskipTests=true -Darchetype.test.skip=true -Dgpg.skip=true -Dstyle.color=always -B -ntp -pl -:ide-macos-installer -pl -:ide-windows-installer -Passembly,msi,pkg,deploy deploy diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6196dad2..a05add50 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -160,7 +160,7 @@ jobs: git tag -a "release/${RELEASE_VERSION}" -m "tagged version ${RELEASE_VERSION}" export GPG_TTY=$TTY mkdir -p ./cli/target/ - mvn --settings .mvn/settings.xml -B -ntp deploy -Passembly,msi,pkg,deploy -pl -:ide-macos-installer -Dgpg.pin.entry.mode=loopback -Dgpg.passphrase=${{ secrets.GPG_PASSPHRASE }} + mvn --settings .mvn/settings.xml -B -ntp deploy -Passembly,msi,pkg,deploy -pl -:ide-macos-installer -pl -:ide-windows-installer -Dgpg.pin.entry.mode=loopback -Dgpg.passphrase=${{ secrets.GPG_PASSPHRASE }} env: RELEASE_VERSION: ${{ needs.resolve-version.outputs.release_version }} SNAPSHOT_VERSION: ${{ needs.resolve-version.outputs.snapshot_version }} diff --git a/.gitignore b/.gitignore index dc1d7309..8c049b2a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,11 @@ macos-installer/Resources/LICENSE.rtf macos-installer/pkg-root macos-installer/Distribution.xml +#windows installer files +*.msi +windows-installer/msi-files +windows-installer/signed + # Package Files # *.jar *.war diff --git a/pom.xml b/pom.xml index b6daeff0..464138a1 100644 --- a/pom.xml +++ b/pom.xml @@ -188,6 +188,7 @@ url-updater security macos-installer + windows-installer diff --git a/windows-installer/pom.xml b/windows-installer/pom.xml new file mode 100644 index 00000000..0741a25e --- /dev/null +++ b/windows-installer/pom.xml @@ -0,0 +1,49 @@ + + + + 4.0.0 + ide-windows-installer + ${revision} + ${project.artifactId} + pom + + + com.devonfw.tools.IDEasy.dev + ide + dev-SNAPSHOT + ../pom.xml + + com.devonfw.tools.IDEasy + + + + + + org.apache.maven.plugins + maven-clean-plugin + 3.5.0 + + + + ./ + + msi-files/ + signed/ + *.msi + + false + + + + + + + From d503484f8724260408b847b6aec74b8bb044d480 Mon Sep 17 00:00:00 2001 From: Franck Majeste Silatsa <105231573+majesteSil@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:52:17 +0200 Subject: [PATCH 35/66] #2344: align macOS PKG build script with Windows MSI build script (#2443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jörg Hohwiller --- .github/workflows/nightly-build.yml | 2 +- .github/workflows/release.yml | 2 +- .../contributing/coding-conventions.adoc | 16 ++++++ macos-installer/README.adoc | 56 +++++++++++++++++++ .../build-pkg.sh | 0 5 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 macos-installer/README.adoc rename .github/workflows/macos/generate_pkg.sh => macos-installer/build-pkg.sh (100%) diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index bac40fde..e4d5ecc5 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -108,7 +108,7 @@ jobs: if: runner.os == 'macOS' shell: bash run: | - ./.github/workflows/macos/generate_pkg.sh + ./macos-installer/build-pkg.sh env: RELEASE_VERSION: ${{ needs.resolve-version.outputs.snapshot_version }} - name: Upload unsigned PKG diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a05add50..0fb031f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,7 +107,7 @@ jobs: if: runner.os == 'macOS' shell: bash run: | - ./.github/workflows/macos/generate_pkg.sh + ./macos-installer/build-pkg.sh env: RELEASE_VERSION: ${{ needs.resolve-version.outputs.release_version }} - name: Upload unsigned PKG diff --git a/documentation/contributing/coding-conventions.adoc b/documentation/contributing/coding-conventions.adoc index 1d4d8c91..91546c7e 100644 --- a/documentation/contributing/coding-conventions.adoc +++ b/documentation/contributing/coding-conventions.adoc @@ -7,6 +7,22 @@ The code should follow general conventions for Java (see http://www.oracle.com/t We consider this as common sense instead of repeating this here. The following sections give us additional conventions that we consider additional. +== Consistency + +We value homogeneity: when in doubt, follow what is already there rather than introducing a +different way of doing the same thing. Concretely: + +* Follow the existing layout. Place a build, script or artifact next to what it belongs to (locality), +not in a detached central location. E.g. the macOS PKG build script lives at +`macos-installer/build-pkg.sh` and the Windows MSI build script at `windows-installer/build-msi.sh` +— each next to the installer it builds, not under `.github/workflows/`. (#2344) +* A later change follows the established pattern. If the repo already does something one way, a +later PR should follow that way instead of adding a second one. +* Prefer reusing existing infrastructure over reinventing the wheel — if Maven (or another build +tool) can do the job, use it. +* Mirror the counterpart for symmetry. When a peer module has something (a `README.adoc`, a +`pom.xml`), a newly added peer should carry the same so the layout stays homogeneous. + == Naming We follow these additional naming rules: diff --git a/macos-installer/README.adoc b/macos-installer/README.adoc new file mode 100644 index 00000000..95afd139 --- /dev/null +++ b/macos-installer/README.adoc @@ -0,0 +1,56 @@ +:toc: macro +toc::[] + +== Building IDEasy PKG Package with pkgbuild/productbuild + +=== Prerequisites + +The build runs on a macOS host (macOS GitHub runners). It needs: + +- macOS with `pkgbuild` and `productbuild` (both ship with the system) +- Maven (supported by IDEasy itself) +- `RELEASE_VERSION` set as an environment variable — provided by the workflow, not set by hand + +=== Steps to Build the PKG Package + +See the `Build MacOS PKG` step in `.github/workflows/release.yml` (and `nightly-build.yml`); +it runs `./macos-installer/build-pkg.sh` **from the repository root** with `RELEASE_VERSION` +provided via `env`. The script assumes the repository root as working directory (it builds +`documentation/` and `cli/` and copies their outputs into `macos-installer/`), so do not run +it from inside `macos-installer/` — that fails with `cd: documentation: No such file or directory`. + +The script assembles `pkg-root/` (generated PDF, `LICENSE.rtf`, the CLI package and the +`ideasy` binary, with the Windows/Linux `system` folders pruned), lets Maven filter +`src/Distribution.xml` into the module (replacing `$[project.version]` with the release version), +then runs `pkgbuild` to build `IDEasyComponent.pkg` from `pkg-root/` and `productbuild` to wrap it +into the distributable `ideasy-.pkg` (arm64 or x64, detected from the runner). The result is +copied to the fixed name `ideasy.pkg` that the `Upload unsigned PKG` step expects. + +=== Files + +link:build-pkg.sh[build-pkg.sh] + +The build script. Fails fast on any error (a PKG built from an incomplete `pkg-root/` would install +a broken IDEasy), requires `RELEASE_VERSION` to be set, and must be run from the repository root. + +link:src/Distribution.xml[src/Distribution.xml] + +The installer GUI configuration (title, license, conclusion, background, `pkg-ref`). Maven filters +this at build time, replacing the `$[project.version]` placeholder with the actual version so the +installer title and the `pkgbuild --version` stay in sync. + +link:Resources[Resources/] + +Installer UI resources: `background.png` and `CONCLUSION.rtf`. `LICENSE.rtf` is copied here at build +time from the generated documentation. + +link:scripts/postinstall[scripts/postinstall] + +Run by the PKG during installation (via `pkgbuild --scripts`); performs the actual IDEasy setup in +the logged-in user's session and shows a modal dialog if it fails. + +link:pom.xml[pom.xml] + +The `ide-macos-installer` module. It filters `src/Distribution.xml` into the module directory and +cleans up the build artifacts (`Distribution.xml`, `*.pkg`, `pkg-root/`, `Resources/LICENSE.rtf`) +for local work. diff --git a/.github/workflows/macos/generate_pkg.sh b/macos-installer/build-pkg.sh similarity index 100% rename from .github/workflows/macos/generate_pkg.sh rename to macos-installer/build-pkg.sh From 941b607e988c2fd54ab2e04ecf54f15a3fc3539d Mon Sep 17 00:00:00 2001 From: Hiepiscus Date: Mon, 28 Sep 2026 11:04:19 +0200 Subject: [PATCH 36/66] #2394: Add documentation for PR retesting (#2414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jörg Hohwiller --- documentation/contributing/retest.adoc | 71 ++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 documentation/contributing/retest.adoc diff --git a/documentation/contributing/retest.adoc b/documentation/contributing/retest.adoc new file mode 100644 index 00000000..24eaa8cb --- /dev/null +++ b/documentation/contributing/retest.adoc @@ -0,0 +1,71 @@ +:toc: +toc::[] + +== Retesting Pull Requests + +After a pull request (PR) has been merged into `main`, it should be retested against the latest upstream snapshot to ensure that it continues to work +correctly after integration. + +Missing or delayed retests can introduce regressions, destabilize upcoming releases and delay the delivery of a new IDEasy version. +Therefore, retesting should be performed continuously as part of the regular QA process. + +=== When to retest + +* After PR was merged +* and you are the PR author +* or you are the reviewer and the original author is unavailable. + +=== Prerequisites + +Before starting the retest process, update your local IDEasy installation to the latest snapshot version so its contains the merged changes: + +[source,bash] +---- +ide upgrade --mode=snapshot +---- + +=== Retest Workflow + +. Ensure the milestone is pointing to the current release (it was correctly updated after the last release). +. https://github.com/orgs/devonfw/projects/5/views/12=[Open the Retests IDEasy board]. +. Select your GitHub username in the *Author* filter. +. Open all listed pull requests in separate browser tabs. +. Review and retest each pull request individually. + +=== Retesting a Pull Request + +For every pull request: + +. Understand the original change and determine how it can be verified. +. Perform the retest using the IDEasy command line. +. Document the result by adding a comment to the pull request. ++ +Example: ++ +---- +Successfully tested on Windows 11 + +ide foo bar + + +---- +. If you discover an issue during retesting: +.. immediately create a new bug issue. +.. Reference the bug issue in your pull request comment. +. Add the `QA approved` label to the pull request once the retest is completed (even if you created and linked a bug) + +NOTE: Sometimes a PR only includes a documentation change, a code cleanup, a test improvement, a workflow or URL-updater change, +or something else that cannot be tested end-to-end on the commandline. In such case comment your observation e.g. `test changes merged and +CI is still green` or `URL-Updater passed and result can be found here` (deep-link to ide-urls pointing to the new tool/edition added). + +=== Retest Result Expectations + +A successful retest comment should contain: + +* The operating system used for testing. +* The executed command(s). +* Relevant output or observations. +* A clear statement whether the retest passed or failed. + +If the retest fails, always create and link a dedicated bug issue before continuing. +This ensures that regressions are tracked and can be addressed before the next IDEasy release. From a5f41aa5dcdfe1ac787d5cacd4b2be38bcb86a5b Mon Sep 17 00:00:00 2001 From: devonfw-core Date: Mon, 28 Sep 2026 12:38:30 +0000 Subject: [PATCH 37/66] Update quality status documents --- .../functional-labels.svg | 30 ++-- .../quality-status-images/issue-age.svg | 22 +-- .../issue-assignment.svg | 10 +- .../quality-status-images/issue-types.svg | 18 +- .../operating-systems.svg | 24 +-- .../quality-status/quality-status-linux.adoc | 16 +- .../quality-status/quality-status-macos.adoc | 23 +-- .../quality-status-windows.adoc | 20 +-- .../quality-status/quality-status.adoc | 162 ++++++++++++------ 9 files changed, 181 insertions(+), 144 deletions(-) diff --git a/documentation/quality-status/quality-status-images/functional-labels.svg b/documentation/quality-status/quality-status-images/functional-labels.svg index 7393fa21..5c003e41 100644 --- a/documentation/quality-status/quality-status-images/functional-labels.svg +++ b/documentation/quality-status/quality-status-images/functional-labels.svg @@ -28,35 +28,35 @@ 26 -install +commandlet 25 -commandlet +install - -21 + +24 vscode - -14 -AI + +19 +settings - -12 -settings + +14 +intellij 12 -intellij +AI 11 -urls +CLI - -11 -CLI + +10 +urls 10 diff --git a/documentation/quality-status/quality-status-images/issue-age.svg b/documentation/quality-status/quality-status-images/issue-age.svg index 47784eed..059507dc 100644 --- a/documentation/quality-status/quality-status-images/issue-age.svg +++ b/documentation/quality-status/quality-status-images/issue-age.svg @@ -20,22 +20,22 @@ Issue age Issue age - + -0-10 days: 46 (21.4%) - +0-10 days: 41 (18.0%) + -11-30 days: 36 (16.7%) - +11-30 days: 57 (25.0%) + -31-60 days: 37 (17.2%) - +31-60 days: 36 (15.8%) + -61-90 days: 13 (6.0%) - +61-90 days: 16 (7.0%) + -90+ days: 83 (38.6%) +90+ days: 78 (34.2%) -215 +228 total diff --git a/documentation/quality-status/quality-status-images/issue-assignment.svg b/documentation/quality-status/quality-status-images/issue-assignment.svg index a21978cc..23188b55 100644 --- a/documentation/quality-status/quality-status-images/issue-assignment.svg +++ b/documentation/quality-status/quality-status-images/issue-assignment.svg @@ -20,13 +20,13 @@ Assigned vs unassigned issues Assigned vs unassigned issues - + -Assigned: 139 (64.7%) - +Assigned: 143 (62.7%) + -Unassigned: 76 (35.3%) +Unassigned: 85 (37.3%) -215 +228 total diff --git a/documentation/quality-status/quality-status-images/issue-types.svg b/documentation/quality-status/quality-status-images/issue-types.svg index 798503ac..36956ade 100644 --- a/documentation/quality-status/quality-status-images/issue-types.svg +++ b/documentation/quality-status/quality-status-images/issue-types.svg @@ -20,19 +20,19 @@ Issue type distribution Issue type distribution - + -Bugs: 40 (18.6%) - +Bugs: 42 (18.4%) + -Tasks: 79 (36.7%) - +Tasks: 91 (39.9%) + -Features: 84 (39.1%) - +Features: 87 (38.2%) + -No Type: 12 (5.6%) +No Type: 8 (3.5%) -215 +228 total diff --git a/documentation/quality-status/quality-status-images/operating-systems.svg b/documentation/quality-status/quality-status-images/operating-systems.svg index baf5b901..cab8b40c 100644 --- a/documentation/quality-status/quality-status-images/operating-systems.svg +++ b/documentation/quality-status/quality-status-images/operating-systems.svg @@ -27,18 +27,18 @@ Cross-platform Windows - - - -195 + + + +213 Linux - - - -189 + + + +207 macOS - - - -195 + + + +212 diff --git a/documentation/quality-status/quality-status-linux.adoc b/documentation/quality-status/quality-status-linux.adoc index f1e38af3..264e5efe 100644 --- a/documentation/quality-status/quality-status-linux.adoc +++ b/documentation/quality-status/quality-status-linux.adoc @@ -9,13 +9,13 @@ Automatically generated open issue overview for Linux. link:quality-status.adoc[Back to overview] -*Total Issues:* 189 +*Total Issues:* 207 -* OS-specific: 6 +* OS-specific: 4 * Multi-OS: 3 -* Cross-platform: 180 +* Cross-platform: 200 == Linux Specific Issues @@ -27,14 +27,8 @@ link:quality-status.adoc[Back to overview] | link:https://github.com/devonfw/IDEasy/issues/2345[#2345] | DOCKER_EDITION ignored on Linux -| link:https://github.com/devonfw/IDEasy/issues/2271[#2271] -| ide install pgadmin fails on Ubuntu Linux - 2+^| *90+ days* -| link:https://github.com/devonfw/IDEasy/issues/2007[#2007] -| IDEasy can not install docker on arch linux - | link:https://github.com/devonfw/IDEasy/issues/1352[#1352] | VSCode WSL warning prompt blocking VSCode plugin installation @@ -53,7 +47,7 @@ link:quality-status.adoc[Back to overview] [%header, cols="^1,7"] |=== | Issue | Summary -2+^| *0-10 days* +2+^| *11-30 days* | link:https://github.com/devonfw/IDEasy/issues/2464[#2464] | Add unit tests for `OperatingSystem` @@ -65,7 +59,7 @@ link:quality-status.adoc[Back to overview] [%header, cols="^1,7"] |=== | Issue | Summary -2+^| *0-10 days* +2+^| *11-30 days* | link:https://github.com/devonfw/IDEasy/issues/2467[#2467] | Obsidian: fix installation on macOS/Linux and duplicate LINUX/X64 artifact in O… diff --git a/documentation/quality-status/quality-status-macos.adoc b/documentation/quality-status/quality-status-macos.adoc index 4e6c487b..7746487c 100644 --- a/documentation/quality-status/quality-status-macos.adoc +++ b/documentation/quality-status/quality-status-macos.adoc @@ -9,32 +9,24 @@ Automatically generated open issue overview for macOS. link:quality-status.adoc[Back to overview] -*Total Issues:* 195 +*Total Issues:* 212 -* OS-specific: 12 +* OS-specific: 9 * Multi-OS: 3 -* Cross-platform: 180 +* Cross-platform: 200 == macOS Specific Issues [%header, cols="^1,7"] |=== | Issue | Summary -2+^| *0-10 days* +2+^| *11-30 days* | link:https://github.com/devonfw/IDEasy/issues/2468[#2468] | Global tool commandlets ignore edition for name/version detection (Windows regi… -2+^| *31-60 days* - -| link:https://github.com/devonfw/IDEasy/issues/2344[#2344] -| Small cleanup: align macos pkg build script with windows msi build script - -| link:https://github.com/devonfw/IDEasy/issues/2206[#2206] -| IDEasy title not properly shown on macOS - 2+^| *90+ days* | link:https://github.com/devonfw/IDEasy/issues/1512[#1512] @@ -61,9 +53,6 @@ link:quality-status.adoc[Back to overview] | link:https://github.com/devonfw/IDEasy/issues/798[#798] | pgadmin not working on Mac -| link:https://github.com/devonfw/IDEasy/issues/377[#377] -| Support Homebrew for installation on macOS - |=== == Multi-OS Issues @@ -73,7 +62,7 @@ link:quality-status.adoc[Back to overview] [%header, cols="^1,7"] |=== | Issue | Summary -2+^| *0-10 days* +2+^| *11-30 days* | link:https://github.com/devonfw/IDEasy/issues/2464[#2464] | Add unit tests for `OperatingSystem` @@ -85,7 +74,7 @@ link:quality-status.adoc[Back to overview] [%header, cols="^1,7"] |=== | Issue | Summary -2+^| *0-10 days* +2+^| *11-30 days* | link:https://github.com/devonfw/IDEasy/issues/2467[#2467] | Obsidian: fix installation on macOS/Linux and duplicate LINUX/X64 artifact in O… diff --git a/documentation/quality-status/quality-status-windows.adoc b/documentation/quality-status/quality-status-windows.adoc index 45436185..7c907bb1 100644 --- a/documentation/quality-status/quality-status-windows.adoc +++ b/documentation/quality-status/quality-status-windows.adoc @@ -9,20 +9,20 @@ Automatically generated open issue overview for Windows. link:quality-status.adoc[Back to overview] -*Total Issues:* 195 +*Total Issues:* 213 -* OS-specific: 14 +* OS-specific: 12 * Multi-OS: 1 -* Cross-platform: 180 +* Cross-platform: 200 == Windows Specific Issues [%header, cols="^1,7"] |=== | Issue | Summary -2+^| *0-10 days* +2+^| *11-30 days* | link:https://github.com/devonfw/IDEasy/issues/2473[#2473] | Uninstalling IDEasy from "Programs & features"/"Apps" screen does not work on w… @@ -30,14 +30,6 @@ link:quality-status.adoc[Back to overview] | link:https://github.com/devonfw/IDEasy/issues/2472[#2472] | Multiple IDEasy entries in Windows Control Center -2+^| *11-30 days* - -| link:https://github.com/devonfw/IDEasy/issues/2442[#2442] -| Add pom.xml to windows-installer and align its build with the maven-driven macO… - -| link:https://github.com/devonfw/IDEasy/issues/2431[#2431] -| Improve uv support to make it usable for IDE_ROOT on non-default drive - | link:https://github.com/devonfw/IDEasy/issues/2406[#2406] | Prepare IDEasy for Microsoft Store App Submission @@ -49,6 +41,8 @@ link:quality-status.adoc[Back to overview] | link:https://github.com/devonfw/IDEasy/issues/2342[#2342] | Display ProgressBar progress in windows taskbar +2+^| *61-90 days* + | link:https://github.com/devonfw/IDEasy/issues/2226[#2226] | Show IDEasy project, workspace and repository in the Git Bash / Windows Termina… @@ -81,7 +75,7 @@ link:quality-status.adoc[Back to overview] [%header, cols="^1,7"] |=== | Issue | Summary -2+^| *0-10 days* +2+^| *11-30 days* | link:https://github.com/devonfw/IDEasy/issues/2464[#2464] | Add unit tests for `OperatingSystem` diff --git a/documentation/quality-status/quality-status.adoc b/documentation/quality-status/quality-status.adoc index c75ac2fc..1b31bd54 100644 --- a/documentation/quality-status/quality-status.adoc +++ b/documentation/quality-status/quality-status.adoc @@ -20,7 +20,7 @@ image::quality-status-images/issue-assignment.svg[Pie chart: assigned vs unassig image::quality-status-images/issue-types.svg[Pie chart: issue type distribution,width=100%] -_Generated: 2026-09-21 11:43 UTC_ +_Generated: 2026-09-28 12:38 UTC_ == Operating System Status Files @@ -29,7 +29,7 @@ Issues are assigned to operating systems based on their labels: `windows`, `linux`, or `macOS`. Issues without an operating system label are treated as cross-platform. -*A total of 180 cross-platform issues* are documented centrally in this document and are therefore not repeated in the operating system specific files. +*A total of 200 cross-platform issues* are documented centrally in this document and are therefore not repeated in the operating system specific files. The detailed tool status is split into one generated file per operating system. @@ -58,6 +58,108 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | Issue | Summary 2+^a| [[cross-platform-0-10-days]] *0-10 days* +| link:https://github.com/devonfw/IDEasy/issues/2572[#2572] +| Make VSCODE_PROFILE_ENABLED ready to be enabled by default + +| link:https://github.com/devonfw/IDEasy/issues/2571[#2571] +| ide vscode fails and leaves plugins that are never reinstalled when VSCode is u… + +| link:https://github.com/devonfw/IDEasy/issues/2570[#2570] +| VSCode login callbacks (vscode://) do not reach the IDEasy window when another… + +| link:https://github.com/devonfw/IDEasy/issues/2569[#2569] +| VSCode plugins installed with VSCODE_PROFILE_ENABLED are missing after the togg… + +| link:https://github.com/devonfw/IDEasy/issues/2568[#2568] +| VSCode user settings from the settings repository are ignored when VSCODE_PROFI… + +| link:https://github.com/devonfw/IDEasy/issues/2567[#2567] +| Deduplicate logo assets copied to gui and cli + +| link:https://github.com/devonfw/IDEasy/issues/2562[#2562] +| Link a current IDEasy dev team to project 5 + +| link:https://github.com/devonfw/IDEasy/issues/2561[#2561] +| python in pristine software repo with per-project isolation + +| link:https://github.com/devonfw/IDEasy/issues/2559[#2559] +| Homebrew formula publish fails: Error: No available formula with the name "ide… + +| link:https://github.com/devonfw/IDEasy/issues/2553[#2553] +| Add release support for yarn + +| link:https://github.com/devonfw/IDEasy/issues/2552[#2552] +| Add release support for npm + +| link:https://github.com/devonfw/IDEasy/issues/2551[#2551] +| Add release support for Gradle + +| link:https://github.com/devonfw/IDEasy/issues/2550[#2550] +| Add release support for remaining build tools + +| link:https://github.com/devonfw/IDEasy/issues/2549[#2549] +| Improvement of testing instructions in `contribution.adoc` + +| link:https://github.com/devonfw/IDEasy/issues/2547[#2547] +| Figma design for pro-active notifications of updates in the GUI + +| link:https://github.com/devonfw/IDEasy/issues/2546[#2546] +| Include repository into git warning/errors + +| link:https://github.com/devonfw/IDEasy/issues/2545[#2545] +| Workspace configured twice + +| link:https://github.com/devonfw/IDEasy/issues/2544[#2544] +| Native package manager commands inherit IDEasy tool environment + +| link:https://github.com/devonfw/IDEasy/issues/2541[#2541] +| Testing inverseMerge fails + +| link:https://github.com/devonfw/IDEasy/issues/2538[#2538] +| Test the bugreport commandlet + +| link:https://github.com/devonfw/IDEasy/issues/2537[#2537] +| Enhace ide upgrade-settings to remove plugin_ legacy prefix + +| link:https://github.com/devonfw/IDEasy/issues/2536[#2536] +| Remove plugin_ prefixes from plugin properties + +| link:https://github.com/devonfw/IDEasy/issues/2535[#2535] +| Cleanup ide-settings + +| link:https://github.com/devonfw/IDEasy/issues/2531[#2531] +| JetBreains: store metadata (.intellij) out of workspace + +| link:https://github.com/devonfw/IDEasy/issues/2530[#2530] +| GitContext.fetch should fetch all remotes if none is specified + +| link:https://github.com/devonfw/IDEasy/issues/2528[#2528] +| Simplify GitContext test infrastructure by consolidating mocks + +| link:https://github.com/devonfw/IDEasy/issues/2527[#2527] +| Cleanup of IDEasy title not properly shown on macOS + +| link:https://github.com/devonfw/IDEasy/issues/2521[#2521] +| Add commandlet for Scala CLI (`scala-cli`) + +| link:https://github.com/devonfw/IDEasy/issues/2520[#2520] +| Add commandlet for Dart SDK (`dart`) + +| link:https://github.com/devonfw/IDEasy/issues/2519[#2519] +| Add commandlet for Flutter (`flutter`) + +| link:https://github.com/devonfw/IDEasy/issues/2518[#2518] +| Add commandlet for Snyk CLI (`snyk`) + +| link:https://github.com/devonfw/IDEasy/issues/2517[#2517] +| Add commandlet for jq (`jq`) + +| link:https://github.com/devonfw/IDEasy/issues/2516[#2516] +| Add commandlet for pnpm (`pnpm`) + +| link:https://github.com/devonfw/IDEasy/issues/2515[#2515] +| Add commandlet for KubeLinter (`kube-linter`) + | link:https://github.com/devonfw/IDEasy/issues/2514[#2514] | Add commandlet for Ollama CLI (`ollama`) @@ -73,18 +175,14 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2510[#2510] | Add commandlet for Micronaut CLI (`mn`) -| link:https://github.com/devonfw/IDEasy/issues/2509[#2509] -| VSCode user settings from the settings repository are ignored since user-data m… - -| link:https://github.com/devonfw/IDEasy/issues/2506[#2506] -| test title - | link:https://github.com/devonfw/IDEasy/issues/2504[#2504] | VSCode plugins cannot be reinstalled after the plugins folder was reset when VS… | link:https://github.com/devonfw/IDEasy/issues/2502[#2502] | Replace global IdeStartContext lookup with ScopedValue +2+^a| [[cross-platform-11-30-days]] *11-30 days* + | link:https://github.com/devonfw/IDEasy/issues/2495[#2495] | Help text and documentation for the --project flag @@ -121,9 +219,6 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2478[#2478] | Move variable resolution out of RepositoryProperties into the transformation to… -| link:https://github.com/devonfw/IDEasy/issues/2477[#2477] -| CliAbortException should always be rethrown in a Step - | link:https://github.com/devonfw/IDEasy/issues/2471[#2471] | VSCode plugin installation fails with "Profile '' not found" when VSCODE_… @@ -148,9 +243,6 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2459[#2459] | Consider integration of gemini -| link:https://github.com/devonfw/IDEasy/issues/2458[#2458] -| Create UrlUpdater for antigravity - | link:https://github.com/devonfw/IDEasy/issues/2457[#2457] | Consider integration of antigravity @@ -181,13 +273,11 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2448[#2448] | Add Native AI Integration Support for IDEasy -2+^a| [[cross-platform-11-30-days]] *11-30 days* - | link:https://github.com/devonfw/IDEasy/issues/2440[#2440] | Add a possibility to open the IdeLauncher Tab when it's closed | link:https://github.com/devonfw/IDEasy/issues/2439[#2439] -| Implement AlertFactory for the GUI +| Implement PopUps, PopUp base classes, PopUpFactory for the GUI | link:https://github.com/devonfw/IDEasy/issues/2436[#2436] | Plugin step progress index broken @@ -204,9 +294,6 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2423[#2423] | isRepositoryUpdateAvailable is not covered by any test that reaches the compari… -| link:https://github.com/devonfw/IDEasy/issues/2422[#2422] -| Cleanup how to determine installed edition and version and potentially restore… - | link:https://github.com/devonfw/IDEasy/issues/2420[#2420] | Improve BuildTool support further @@ -216,9 +303,6 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2417[#2417] | Analysis of OpenShell Nvidia for AI Agent -| link:https://github.com/devonfw/IDEasy/issues/2411[#2411] -| Improve overview of projects which use IDEasy - | link:https://github.com/devonfw/IDEasy/issues/2410[#2410] | Improve support for bug report @@ -237,9 +321,6 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2402[#2402] | Add base-classes for GUI tabs -| link:https://github.com/devonfw/IDEasy/issues/2401[#2401] -| Rework MainWindow - | link:https://github.com/devonfw/IDEasy/issues/2400[#2400] | Rework Console to MVVM @@ -249,18 +330,14 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2397[#2397] | GUI infrastructure epic -| link:https://github.com/devonfw/IDEasy/issues/2394[#2394] -| Add documentation for PR Retest and QA approval - | link:https://github.com/devonfw/IDEasy/issues/2393[#2393] | Open a project folder of my choice (e.g. an external Git repo) instead of alway… +2+^a| [[cross-platform-31-60-days]] *31-60 days* + | link:https://github.com/devonfw/IDEasy/issues/2385[#2385] | Cleanup of IdeLogEntry and IdeGuiListener -| link:https://github.com/devonfw/IDEasy/issues/2381[#2381] -| node + npm in pristine software repo with per-project isolation (Bash, launcher… - | link:https://github.com/devonfw/IDEasy/issues/2374[#2374] | Hotfix: Auto Assign PR Author workflow fails with 403 Forbidden for bot-created… @@ -279,8 +356,6 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2364[#2364] | Error during installation of soapui -2+^a| [[cross-platform-31-60-days]] *31-60 days* - | link:https://github.com/devonfw/IDEasy/issues/2358[#2358] | Overhaul IDEasy project board review assigning @@ -326,9 +401,6 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2280[#2280] | Maven commandlet runs from workspace root instead of project directory -| link:https://github.com/devonfw/IDEasy/issues/2279[#2279] -| Desktop shortcut for the GUI does not work: gui commandlet requires IDE_HOME - | link:https://github.com/devonfw/IDEasy/issues/2275[#2275] | Auto-install git if not already installed @@ -365,8 +437,7 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2240[#2240] | Support file and folder selection -| link:https://github.com/devonfw/IDEasy/issues/2236[#2236] -| Preserve Git Bash Working Directory When Launching VS Code via ide vscode +2+^a| [[cross-platform-61-90-days]] *61-90 days* | link:https://github.com/devonfw/IDEasy/issues/2225[#2225] | Define installation ownership for Homebrew: brew and 'ideasy install' produce a… @@ -374,8 +445,6 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/2214[#2214] | Overhaul language selection logic -2+^a| [[cross-platform-61-90-days]] *61-90 days* - | link:https://github.com/devonfw/IDEasy/issues/2200[#2200] | Add support to check repository for best-practices @@ -392,7 +461,7 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | Basic structure for Commandlet view | link:https://github.com/devonfw/IDEasy/issues/2142[#2142] -| Move IDE-specific metada (.idea, .vscode) out of workspace +| Move IDE-specific metada (.intellij, .vscode) out of workspace | link:https://github.com/devonfw/IDEasy/issues/2111[#2111] | Add user documentation for GUI @@ -450,9 +519,6 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/1814[#1814] | IDEasy still reports updates for settings are available after any number of inv… -| link:https://github.com/devonfw/IDEasy/issues/1808[#1808] -| icd is not navigating into single available workspace on icd -p project - | link:https://github.com/devonfw/IDEasy/issues/1787[#1787] | tracking yarn versions broken @@ -537,9 +603,6 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/882[#882] | Skip step if no installation was required -| link:https://github.com/devonfw/IDEasy/issues/840[#840] -| git fetch does not work with multiple remotes - | link:https://github.com/devonfw/IDEasy/issues/785[#785] | Re-introduce workspace reverse options from devonfw-ide @@ -561,9 +624,6 @@ image::quality-status-images/functional-labels.svg[Bar chart: most common functi | link:https://github.com/devonfw/IDEasy/issues/629[#629] | Recycle devonfw-ide documentation -| link:https://github.com/devonfw/IDEasy/issues/607[#607] -| Test for environment variables of process contexts - | link:https://github.com/devonfw/IDEasy/issues/492[#492] | Use Maven dependency cache to optimize workflow running times From cc2e6f8b8d786af66fc62ede43d489bd0f5e3a33 Mon Sep 17 00:00:00 2001 From: JoelAdbu Date: Tue, 29 Sep 2026 09:03:36 +0200 Subject: [PATCH 38/66] #2293: rework spyder as ide tool commandlet (#2333) --- CHANGELOG.adoc | 1 + .../devonfw/tools/ide/cli/CliSuggester.java | 23 +- .../ide/commandlet/AbstractCommandlet.java | 277 ++++ ...AbstractVersionOrEditionGetCommandlet.java | 28 +- .../tools/ide/commandlet/BuildCommandlet.java | 8 +- .../tools/ide/commandlet/Commandlet.java | 264 +--- .../ide/commandlet/CommandletManager.java | 90 +- .../ide/commandlet/CommandletManagerImpl.java | 62 +- .../ide/commandlet/CompleteCommandlet.java | 4 +- .../ide/commandlet/ContextCommandlet.java | 2 +- .../ide/commandlet/CreateCommandlet.java | 2 +- .../ide/commandlet/EditionGetCommandlet.java | 10 +- .../ide/commandlet/EditionListCommandlet.java | 10 +- .../ide/commandlet/EditionSetCommandlet.java | 8 +- .../ide/commandlet/EnvironmentCommandlet.java | 10 +- .../tools/ide/commandlet/HelpCommandlet.java | 12 +- .../ide/commandlet/InstallCommandlet.java | 12 +- .../commandlet/InstallPluginCommandlet.java | 12 +- .../tools/ide/commandlet/LnCommandlet.java | 4 +- .../ide/commandlet/ReleaseCommandlet.java | 10 +- .../ide/commandlet/RewriteCommandlet.java | 6 +- .../tools/ide/commandlet/ShellCommandlet.java | 17 +- .../ide/commandlet/StatusCommandlet.java | 4 +- .../ide/commandlet/TruststoreCommandlet.java | 10 +- .../ide/commandlet/UninstallCommandlet.java | 8 +- .../commandlet/UninstallPluginCommandlet.java | 12 +- .../ide/commandlet/UnpackCommandlet.java | 4 +- .../ide/commandlet/UpgradeCommandlet.java | 4 +- .../commandlet/UpgradeSettingsCommandlet.java | 4 +- .../ide/commandlet/VersionCommandlet.java | 2 +- .../ide/commandlet/VersionGetCommandlet.java | 10 +- .../ide/commandlet/VersionListCommandlet.java | 10 +- .../ide/commandlet/VersionSetCommandlet.java | 12 +- .../ide/commandlet/check/CheckCommandlet.java | 6 +- .../commandlet/cleanup/CleanupCommandlet.java | 4 +- .../update/AbstractUpdateCommandlet.java | 22 +- .../commandlet/update/UpdateCommandlet.java | 3 +- .../completion/AutoCompletionRegistry.java | 6 +- .../CompletionCandidateCollector.java | 16 +- .../CompletionCandidateCollectorAdapter.java | 4 +- .../CompletionCandidateCollectorDefault.java | 4 +- .../tools/ide/completion/CompletionEntry.java | 10 +- .../tools/ide/context/AbstractIdeContext.java | 54 +- .../devonfw/tools/ide/context/IdeContext.java | 6 +- .../ide/environment/EnvironmentVariables.java | 7 +- .../environment/EnvironmentVariablesType.java | 3 +- .../git/repository/RepositoryCommandlet.java | 16 +- .../devonfw/tools/ide/log/IdeLogLevel.java | 3 +- .../tools/ide/merge/DirectoryMerger.java | 2 + .../devonfw/tools/ide/merge/IniMerger.java | 201 +++ .../tools/ide/merge/WorkspaceMerger.java | 3 +- .../com/devonfw/tools/ide/nls/NlsBundle.java | 14 +- .../com/devonfw/tools/ide/os/MacOsHelper.java | 6 +- .../tools/ide/property/BooleanProperty.java | 4 +- .../ide/property/CommandletProperty.java | 22 +- .../tools/ide/property/EnumProperty.java | 4 +- .../tools/ide/property/KeywordProperty.java | 6 +- .../tools/ide/property/LocaleProperty.java | 4 +- .../tools/ide/property/PathProperty.java | 15 +- .../tools/ide/property/PluginProperty.java | 10 +- .../devonfw/tools/ide/property/Property.java | 32 +- .../ide/property/RepositoryProperty.java | 6 +- .../ide/property/ToolArgumentsProperty.java | 12 +- .../tools/ide/property/ToolProperty.java | 21 +- .../tools/ide/property/VersionProperty.java | 10 +- .../ide/tool/AbstractLocalToolCommandlet.java | 647 +++++++++ .../ide/tool/AbstractToolCommandlet.java | 1169 +++++++++++++++++ .../ide/tool/DelegatingToolCommandlet.java | 8 +- .../tools/ide/tool/GlobalToolCommandlet.java | 24 +- .../tools/ide/tool/LocalToolCommandlet.java | 646 +-------- ...ackageManagerBasedLocalToolCommandlet.java | 12 +- .../tools/ide/tool/PackageManagerRequest.java | 12 +- .../tools/ide/tool/ToolCommandlet.java | 1168 +--------------- .../devonfw/tools/ide/tool/ToolEdition.java | 4 +- .../ide/tool/androidstudio/AndroidStudio.java | 3 +- .../com/devonfw/tools/ide/tool/aws/Aws.java | 6 +- .../com/devonfw/tools/ide/tool/az/Azure.java | 7 +- .../com/devonfw/tools/ide/tool/cdk/Cdk.java | 3 +- .../devonfw/tools/ide/tool/claude/Claude.java | 21 +- .../tools/ide/tool/copilot/Copilot.java | 7 +- .../tools/ide/tool/corepack/Corepack.java | 3 +- .../ide/tool/custom/CustomToolCommandlet.java | 6 +- .../tool/custom/CustomToolRepositoryImpl.java | 8 +- .../devonfw/tools/ide/tool/dotnet/DotNet.java | 6 +- .../tools/ide/tool/eclipse/Eclipse.java | 8 +- .../ide/tool/extra/ExtraToolInstallation.java | 4 +- .../tools/ide/tool/extra/ExtraTools.java | 5 +- .../ide/tool/gcloganalyzer/GcLogAnalyzer.java | 7 +- .../tools/ide/tool/gcviewer/GcViewer.java | 7 +- .../com/devonfw/tools/ide/tool/gh/Gh.java | 7 +- .../com/devonfw/tools/ide/tool/go/Go.java | 9 +- .../tools/ide/tool/graalvm/GraalVm.java | 6 +- .../devonfw/tools/ide/tool/gradle/Gradle.java | 7 +- .../com/devonfw/tools/ide/tool/gui/Gui.java | 6 +- .../com/devonfw/tools/ide/tool/helm/Helm.java | 7 +- .../tool/ide/AbstractIdeToolCommandlet.java | 124 ++ .../tools/ide/tool/ide/IdeToolCommandlet.java | 282 +--- .../ide/tool/ide/IdeWorkspaceConfigurer.java | 192 +++ .../tool/ide/IdeaBasedIdeToolCommandlet.java | 4 +- .../com/devonfw/tools/ide/tool/inso/Inso.java | 10 +- .../tools/ide/tool/intellij/Intellij.java | 12 +- .../devonfw/tools/ide/tool/jasypt/Jasypt.java | 10 +- .../com/devonfw/tools/ide/tool/java/Java.java | 7 +- .../com/devonfw/tools/ide/tool/jmc/Jmc.java | 10 +- .../tools/ide/tool/kotlinc/Kotlinc.java | 7 +- .../tools/ide/tool/kotlinc/KotlincNative.java | 7 +- .../tools/ide/tool/lazydocker/LazyDocker.java | 7 +- .../com/devonfw/tools/ide/tool/msvc/Msvc.java | 4 +- .../tools/ide/tool/mvn/MavenCommandlet.java | 4 +- .../com/devonfw/tools/ide/tool/mvn/Mvn.java | 3 +- .../tool/mvn/MvnBasedLocalToolCommandlet.java | 6 +- .../tools/ide/tool/mvn/MvnRepository.java | 13 +- .../com/devonfw/tools/ide/tool/mvnd/Mvnd.java | 3 +- .../com/devonfw/tools/ide/tool/nest/Nest.java | 3 +- .../com/devonfw/tools/ide/tool/ng/Ng.java | 3 +- .../com/devonfw/tools/ide/tool/node/Node.java | 6 +- .../ide/tool/node/NodeBasedCommandlet.java | 9 +- .../com/devonfw/tools/ide/tool/npm/Npm.java | 6 +- .../ide/tool/npm/NpmBasedCommandlet.java | 3 +- .../tools/ide/tool/npm/NpmRepository.java | 4 +- .../com/devonfw/tools/ide/tool/oc/Oc.java | 7 +- .../com/devonfw/tools/ide/tool/pip/Pip.java | 3 +- .../ide/tool/pip/PipBasedCommandlet.java | 8 +- .../tool/pip/PipBasedIdeToolCommandlet.java | 149 ++- .../tools/ide/tool/pip/PipRepository.java | 4 +- .../plugin/AbstractPluginBasedCommandlet.java | 113 ++ .../tool/plugin/PluginBasedCommandlet.java | 350 +---- .../tools/ide/tool/plugin/PluginManager.java | 309 +++++ .../tools/ide/tool/pycharm/Pycharm.java | 3 +- .../devonfw/tools/ide/tool/python/Python.java | 7 +- .../ide/tool/python/PythonRepository.java | 6 +- .../tools/ide/tool/quarkus/Quarkus.java | 7 +- .../repository/AbstractToolRepository.java | 10 +- .../repository/ArtifactToolRepository.java | 18 +- .../repository/DefaultToolRepository.java | 6 +- .../ide/tool/repository/ToolRepository.java | 18 +- .../com/devonfw/tools/ide/tool/ruby/Ruby.java | 7 +- .../com/devonfw/tools/ide/tool/rust/Rust.java | 6 +- .../devonfw/tools/ide/tool/soapui/SoapUi.java | 6 +- .../devonfw/tools/ide/tool/sonar/Sonar.java | 6 +- .../devonfw/tools/ide/tool/spring/Spring.java | 3 +- .../devonfw/tools/ide/tool/spyder/Spyder.java | 27 +- .../ide/tool/squirrelsql/SquirrelSql.java | 6 +- .../com/devonfw/tools/ide/tool/task/Task.java | 3 +- .../tools/ide/tool/terraform/Terraform.java | 7 +- .../devonfw/tools/ide/tool/tomcat/Tomcat.java | 8 +- .../com/devonfw/tools/ide/tool/uv/Uv.java | 7 +- .../tools/ide/tool/uv/UvRepository.java | 4 +- .../devonfw/tools/ide/tool/vscode/Vscode.java | 9 +- .../ide/url/model/AbstractUrlMetadata.java | 16 +- .../tools/ide/url/model/UrlMetadata.java | 12 +- .../tools/ide/variable/IdeVariables.java | 4 +- .../tools/ide/version/VersionIdentifier.java | 6 +- .../com/devonfw/tools/ide/cli/IdeasyTest.java | 13 +- .../ide/commandlet/HelpCommandletTest.java | 6 +- .../ide/commandlet/TestCommandletManager.java | 4 +- .../tools/ide/completion/CompleteTest.java | 4 +- ...mpletionCandidateCollectorDefaultTest.java | 4 +- .../ide/context/AbstractIdeTestContext.java | 8 +- .../context/CapturingProcessContextTest.java | 38 + .../tools/ide/merge/IniMergerTest.java | 74 ++ .../ide/property/LocalePropertyTest.java | 4 +- .../ide/property/VersionPropertyTest.java | 8 +- .../tools/ide/tool/CveCheckInstallTest.java | 2 +- .../ide/tool/LocalToolCommandletTest.java | 39 +- .../tool/custom/CustomToolRepositoryTest.java | 6 +- .../ide/tool/ide/IdeToolCommandletTest.java | 6 +- .../tool/ide/IdeToolDummyCommandletTest.java | 10 +- .../tools/ide/tool/mvn/MvnRepositoryTest.java | 20 +- .../pip/ExamplePipBasedIdeToolCommandlet.java | 52 + .../pip/PipBasedIdeToolCommandletTest.java | 86 ++ .../plugin/ExamplePluginBasedCommandlet.java | 4 +- .../plugin/PluginBasedCommandletTest.java | 2 +- .../tool/repository/ToolRepositoryMock.java | 6 +- .../tools/ide/tool/spyder/SpyderTest.java | 30 +- .../tools/ide/tool/vscode/VscodeTest.java | 7 +- .../commandlet-and-url-updater.adoc | 8 +- 177 files changed, 4300 insertions(+), 3356 deletions(-) create mode 100644 cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractCommandlet.java create mode 100644 cli/src/main/java/com/devonfw/tools/ide/merge/IniMerger.java create mode 100644 cli/src/main/java/com/devonfw/tools/ide/tool/AbstractLocalToolCommandlet.java create mode 100644 cli/src/main/java/com/devonfw/tools/ide/tool/AbstractToolCommandlet.java create mode 100644 cli/src/main/java/com/devonfw/tools/ide/tool/ide/AbstractIdeToolCommandlet.java create mode 100644 cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeWorkspaceConfigurer.java create mode 100644 cli/src/main/java/com/devonfw/tools/ide/tool/plugin/AbstractPluginBasedCommandlet.java create mode 100644 cli/src/main/java/com/devonfw/tools/ide/tool/plugin/PluginManager.java create mode 100644 cli/src/test/java/com/devonfw/tools/ide/context/CapturingProcessContextTest.java create mode 100644 cli/src/test/java/com/devonfw/tools/ide/merge/IniMergerTest.java create mode 100644 cli/src/test/java/com/devonfw/tools/ide/tool/pip/ExamplePipBasedIdeToolCommandlet.java create mode 100644 cli/src/test/java/com/devonfw/tools/ide/tool/pip/PipBasedIdeToolCommandletTest.java diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 9201bf38..f50d1a8c 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -37,6 +37,7 @@ The full list of changes for this release can be found in https://github.com/dev == 2026.09.001 +* https://github.com/devonfw/IDEasy/issues/2293[#2293]: Rework spyder to have same features as IdeToolCommandlets Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/1525[#1525]: Document known issue and workaround for lombok plugin in Eclipse diff --git a/cli/src/main/java/com/devonfw/tools/ide/cli/CliSuggester.java b/cli/src/main/java/com/devonfw/tools/ide/cli/CliSuggester.java index 5a27631d..964e9888 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/cli/CliSuggester.java +++ b/cli/src/main/java/com/devonfw/tools/ide/cli/CliSuggester.java @@ -7,7 +7,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.property.EnumProperty; @@ -38,11 +38,11 @@ public CliSuggester(IdeContext context) { /** * Handles the case where a commandlet requires an IDEasy project context (IDE_ROOT/IDE_HOME) but the user is not inside one. * - * @param commandlet the {@link Commandlet} that the user tried to run. + * @param commandlet the {@link AbstractCommandlet} that the user tried to run. * @param step the current {@link StepImpl} for error reporting. * @return {@code true} if handled (message printed), {@code false} otherwise. */ - public boolean isMissingProjectContextHandled(Commandlet commandlet, StepImpl step) { + public boolean isMissingProjectContextHandled(AbstractCommandlet commandlet, StepImpl step) { if (commandlet == null) { return false; @@ -69,11 +69,11 @@ public boolean isMissingProjectContextHandled(Commandlet commandlet, StepImpl st * Handles invalid option errors and suggests corrections. * * @param result the {@link com.devonfw.tools.ide.validation.ValidationResult} from option parsing. - * @param commandlet the {@link Commandlet} that was being executed. + * @param commandlet the {@link AbstractCommandlet} that was being executed. * @param step the current {@link StepImpl} for error reporting. * @return {@code true} if handled (suggestion provided), {@code false} otherwise. */ - public boolean isInvalidOptionHandled(ValidationState result, Commandlet commandlet, StepImpl step) { + public boolean isInvalidOptionHandled(ValidationState result, AbstractCommandlet commandlet, StepImpl step) { if ((result == null) || (commandlet == null)) { return false; @@ -133,10 +133,10 @@ public boolean isMissingCommandletHandled(String commandKey, StepImpl step) { * Handles invalid argument value errors for properties and suggests corrections. * * @param result the {@link com.devonfw.tools.ide.validation.ValidationResult} from argument parsing. - * @param commandlet the {@link Commandlet} that was being executed. + * @param commandlet the {@link AbstractCommandlet} that was being executed. * @return {@code true} if handled (suggestion provided), {@code false} otherwise. */ - public boolean isInvalidArgumentHandled(ValidationState result, Commandlet commandlet) { + public boolean isInvalidArgumentHandled(ValidationState result, AbstractCommandlet commandlet) { if ((result == null) || (commandlet == null)) { return false; @@ -201,10 +201,10 @@ private List getValidValuesForProperty(Property property) { /** * Gets all option names for a commandlet. * - * @param cmd the {@link Commandlet}. + * @param cmd the {@link AbstractCommandlet}. * @return {@link List} of all option names and aliases. */ - private List getAllOptionNames(Commandlet cmd) { + private List getAllOptionNames(AbstractCommandlet cmd) { List opts = new ArrayList<>(); for (Property p : cmd.getProperties()) { @@ -230,7 +230,7 @@ private List getAllOptionNames(Commandlet cmd) { private List getAllCommandletNames() { List names = new ArrayList<>(); - for (Commandlet cmd : this.context.getCommandletManager().getCommandlets()) { + for (AbstractCommandlet cmd : this.context.getCommandletManager().getCommandlets()) { names.add(cmd.getName()); } return names; @@ -244,7 +244,7 @@ private List getAllCommandletNames() { private List getAllToolNames() { List names = new ArrayList<>(); - for (Commandlet cmd : this.context.getCommandletManager().getCommandlets()) { + for (AbstractCommandlet cmd : this.context.getCommandletManager().getCommandlets()) { if (cmd instanceof ToolCommandlet) { names.add(cmd.getName()); } @@ -311,4 +311,3 @@ private int levenshtein(String a, String b) { } } - diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractCommandlet.java new file mode 100644 index 00000000..95770214 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractCommandlet.java @@ -0,0 +1,277 @@ +package com.devonfw.tools.ide.commandlet; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.devonfw.tools.ide.context.AbstractIdeContext; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.nls.NlsBundle; +import com.devonfw.tools.ide.property.KeywordProperty; +import com.devonfw.tools.ide.property.Property; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; +import com.devonfw.tools.ide.validation.ValidationResult; +import com.devonfw.tools.ide.validation.ValidationState; +import com.devonfw.tools.ide.version.VersionIdentifier; + +/** + * A {@link AbstractCommandlet} is a sub-command of the IDE CLI. + */ +public abstract class AbstractCommandlet implements Commandlet { + + /** The {@link IdeContext} instance. */ + protected final IdeContext context; + + private final List> propertiesList; + + private final List> properties; + + private final List> valuesList; + + private final List> values; + + private final Map> optionMap; + + private Property multiValued; + + private KeywordProperty firstKeyword; + + /** + * The constructor. + * + * @param context the {@link IdeContext}. + */ + public AbstractCommandlet(IdeContext context) { + + super(); + this.context = context; + this.propertiesList = new ArrayList<>(); + this.properties = Collections.unmodifiableList(this.propertiesList); + this.valuesList = new ArrayList<>(); + this.values = Collections.unmodifiableList(this.valuesList); + this.optionMap = new HashMap<>(); + } + + /** + * @return the {@link List} with all {@link Property properties} of this {@link AbstractCommandlet}. + */ + public List> getProperties() { + + return this.properties; + } + + /** + * @return the {@link List} of {@link Property properties} that are {@link Property#isValue() values}. + */ + public List> getValues() { + + return this.values; + } + + /** + * Clear the set values on all properties of the {@link AbstractCommandlet#propertiesList} + */ + public void reset() { + + for (Property property : this.propertiesList) { + property.clearValue(); + } + } + + /** + * @param nameOrAlias the potential {@link Property#getName() name} or {@link Property#getAlias() alias} of the requested {@link Property}. + * @return the requested {@link Property property} or {@code null} if not found. + */ + public Property getOption(String nameOrAlias) { + + return this.optionMap.get(nameOrAlias); + } + + /** + * @param keyword the {@link KeywordProperty keyword} to {@link #add(Property) add}. + */ + protected void addKeyword(String keyword) { + + addKeyword(keyword, null); + } + + /** + * @param keyword the {@link KeywordProperty keyword} to {@link #add(Property) add}. + * @param alias the optional {@link KeywordProperty#getAlias() alias}. + */ + protected void addKeyword(String keyword, String alias) { + + KeywordProperty property = new KeywordProperty(keyword, true, alias); + if (this.firstKeyword == null) { + if (!this.properties.isEmpty()) { + throw new IllegalStateException(property + " must be first property in " + getClass().getSimpleName()); + } + this.firstKeyword = property; + } + add(property); + } + + /** + * @param

type of the {@link Property}. + * @param property the {@link Property} to register. + * @return the given {@link Property}. + */ + protected

> P add(P property) { + + if (this.multiValued != null) { + throw new IllegalStateException("The multi-valued property " + this.multiValued + " can not be followed by " + property); + } + this.propertiesList.add(property); + if (property.isOption()) { + add(property.getName(), property, false); + add(property.getAlias(), property, true); + } + if (property.isValue()) { + this.valuesList.add(property); + } + if (property.isMultiValued()) { + this.multiValued = property; + } + return property; + } + + private void add(String name, Property property, boolean alias) { + + if (alias && (name == null)) { + return; + } + Objects.requireNonNull(name); + assert (name.equals(name.trim())); + if (name.isEmpty() && !alias) { + return; + } + Property duplicate = this.optionMap.put(name, property); + if (duplicate != null) { + throw new IllegalStateException("Duplicate name or alias " + name + " for " + property + " and " + duplicate); + } + } + + @Override + public IdeContext getContext() { + + return this.context; + } + + @Override + public abstract String getName(); + + /** + * @return the first keyword of this {@link AbstractCommandlet}. Typically, the same as {@link #getName() name} but may also differ (e.g. "set" vs. + * "set-version"). + */ + public KeywordProperty getFirstKeyword() { + + return this.firstKeyword; + } + + /** + * @param type of the {@link AbstractCommandlet}. + * @param commandletType the {@link Class} reflecting the requested {@link AbstractCommandlet}. + * @return the requested {@link AbstractCommandlet}. + * @see CommandletManager#getCommandlet(Class) + */ + protected C getCommandlet(Class commandletType) { + + return this.context.getCommandletManager().getCommandlet(commandletType); + } + + /** + * @return {@code true} if {@link IdeContext#getIdeHome() IDE_HOME} is required for this commandlet, {@code false} otherwise. + */ + public boolean isIdeHomeRequired() { + + return isIdeRootRequired(); + } + + /** + * @return {@code true} if {@link IdeContext#getIdeRoot() IDE_ROOT} is required for this commandlet, {@code false} otherwise. + */ + public boolean isIdeRootRequired() { + + return true; + } + + /** + * @return {@code true} to suppress the {@link com.devonfw.tools.ide.step.StepImpl#logSummary(boolean) step summary success message}. + */ + public boolean isSuppressStepSuccess() { + + return false; + } + + /** + * @return {@code true} if the output of this commandlet is (potentially) processed automatically from outside, {@code false} otherwise. For example + * {@link CompleteCommandlet} logs the suggestions for auto-completion to a bash script. Also, the {@link EnvironmentCommandlet} logs the environment + * variables for the {@code ide} wrapper script. In such scenarios these logs shall not be spammed with warnings like "IDE_ROOT is not set" that would + * break the processing of the output. + */ + public boolean isProcessableOutput() { + + return false; + } + + /** + * @return {@code true} to write a logfile (unless disabled via {@link com.devonfw.tools.ide.variable.IdeVariables#IDE_WRITE_LOGFILE}), {@code false} + * otherwise. + */ + public boolean isWriteLogFile() { + return !isProcessableOutput(); + } + + /** + * Runs this {@link AbstractCommandlet}. + */ + public final void run() { + + if (this.context != null) { // for ContextCommandlet we do not have a context yet + ((AbstractIdeContext) this.context).configureJavaUtilLogging(this); + } + doRun(); + } + + protected abstract void doRun(); + + /** + * @return {@code true} if this {@link AbstractCommandlet} is the valid candidate to be {@link #run()}, {@code false} otherwise. + * @see Property#validate() + */ + public ValidationResult validate() { + ValidationState state = new ValidationState(null); + // avoid validation exception if not a candidate to be run. + for (Property property : this.propertiesList) { + state.add(property.validate()); + } + return state; + } + + /** + * Provide additional usage help of this {@link AbstractCommandlet} to the user. + * + * @param bundle the {@link NlsBundle} to get I18N messages from. + */ + public void printHelp(NlsBundle bundle) { + + } + + @Override + public String toString() { + + return getClass().getSimpleName() + "[" + getName() + "]"; + } + + /** + * @return the {@link AbstractToolCommandlet} set in a {@link Property} of this commandlet used for auto-completion of a {@link VersionIdentifier} or + * {@link com.devonfw.tools.ide.tool.plugin.ToolPluginDescriptor}, otherwise {@code null} if not exists or not configured. + */ + public AbstractToolCommandlet getToolForCompletion() { + return null; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractVersionOrEditionGetCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractVersionOrEditionGetCommandlet.java index b387e884..8dc91b6a 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractVersionOrEditionGetCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractVersionOrEditionGetCommandlet.java @@ -10,14 +10,14 @@ import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.property.FlagProperty; import com.devonfw.tools.ide.property.ToolProperty; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; /** - * An internal {@link Commandlet} to get the installed version for a tool. + * An internal {@link AbstractCommandlet} to get the installed version for a tool. * - * @see ToolCommandlet#getInstalledVersion() + * @see AbstractToolCommandlet#getInstalledVersion() */ -public abstract class AbstractVersionOrEditionGetCommandlet extends Commandlet { +public abstract class AbstractVersionOrEditionGetCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(AbstractVersionOrEditionGetCommandlet.class); @@ -56,25 +56,25 @@ public boolean isProcessableOutput() { protected abstract String getPropertyToGet(); /** - * @param commandlet the {@link ToolCommandlet} to get the value from. + * @param commandlet the {@link AbstractToolCommandlet} to get the value from. * @return the configured value. - * @see ToolCommandlet#getConfiguredVersion() - * @see ToolCommandlet#getConfiguredEdition() + * @see AbstractToolCommandlet#getConfiguredVersion() + * @see AbstractToolCommandlet#getConfiguredEdition() */ - protected abstract Object getConfiguredValue(ToolCommandlet commandlet); + protected abstract Object getConfiguredValue(AbstractToolCommandlet commandlet); /** - * @param commandlet the {@link ToolCommandlet} to get the value from. + * @param commandlet the {@link AbstractToolCommandlet} to get the value from. * @return the installed value or {@code null} if the tool is not installed. - * @see ToolCommandlet#getInstalledVersion() - * @see ToolCommandlet#getInstalledEdition() + * @see AbstractToolCommandlet#getInstalledVersion() + * @see AbstractToolCommandlet#getInstalledEdition() */ - protected abstract Object getInstalledValue(ToolCommandlet commandlet); + protected abstract Object getInstalledValue(AbstractToolCommandlet commandlet); @Override protected void doRun() { - ToolCommandlet commandlet = this.tool.getValue(); + AbstractToolCommandlet commandlet = this.tool.getValue(); IdeLogLevel level = IdeLogLevel.PROCESSABLE; Object configuredValue = getConfiguredValue(commandlet); Object installedValue = getInstalledValue(commandlet); @@ -108,7 +108,7 @@ protected void doRun() { } } - private void logToolInfo(ToolCommandlet commandlet, Object configuredValue, Object installedValue) { + private void logToolInfo(AbstractToolCommandlet commandlet, Object configuredValue, Object installedValue) { String property = getPropertyToGet(); String toolName = commandlet.getName(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/BuildCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/BuildCommandlet.java index f47cf8f0..ae57cbc2 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/BuildCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/BuildCommandlet.java @@ -7,12 +7,12 @@ import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.property.StringProperty; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * Build tool {@link Commandlet} for automatically detecting build configuration files and running the respective tool. + * Build tool {@link AbstractCommandlet} for automatically detecting build configuration files and running the respective tool. */ -public class BuildCommandlet extends Commandlet { +public class BuildCommandlet extends AbstractCommandlet { /** The explicit build options to use (if empty use defaults). */ public final StringProperty arguments; @@ -44,7 +44,7 @@ protected void doRun() { throw new CliException("Missing current working directory!"); } - LocalToolCommandlet commandlet = this.context.getCommandletManager().findBuildTool(buildPath); + AbstractLocalToolCommandlet commandlet = this.context.getCommandletManager().findBuildTool(buildPath); if (commandlet == null) { throw new CliException("Could not find a build descriptor in " + buildPath + " - no supported build tool detected."); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/Commandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/Commandlet.java index 3534e372..02c37d51 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/Commandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/Commandlet.java @@ -1,272 +1,24 @@ package com.devonfw.tools.ide.commandlet; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -import com.devonfw.tools.ide.context.AbstractIdeContext; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.nls.NlsBundle; -import com.devonfw.tools.ide.property.KeywordProperty; -import com.devonfw.tools.ide.property.Property; -import com.devonfw.tools.ide.tool.ToolCommandlet; -import com.devonfw.tools.ide.validation.ValidationResult; -import com.devonfw.tools.ide.validation.ValidationState; -import com.devonfw.tools.ide.version.VersionIdentifier; /** - * A {@link Commandlet} is a sub-command of the IDE CLI. + * A {@link Commandlet} is a sub-command interface of the IDE CLI. */ -public abstract class Commandlet { - - /** The {@link IdeContext} instance. */ - protected final IdeContext context; - - private final List> propertiesList; - - private final List> properties; - - private final List> valuesList; - - private final List> values; - - private final Map> optionMap; - - private Property multiValued; - - private KeywordProperty firstKeyword; - - /** - * The constructor. - * - * @param context the {@link IdeContext}. - */ - public Commandlet(IdeContext context) { - - super(); - this.context = context; - this.propertiesList = new ArrayList<>(); - this.properties = Collections.unmodifiableList(this.propertiesList); - this.valuesList = new ArrayList<>(); - this.values = Collections.unmodifiableList(this.valuesList); - this.optionMap = new HashMap<>(); - } - - /** - * @return the {@link List} with all {@link Property properties} of this {@link Commandlet}. - */ - public List> getProperties() { - - return this.properties; - } - - /** - * @return the {@link List} of {@link Property properties} that are {@link Property#isValue() values}. - */ - public List> getValues() { - - return this.values; - } - - /** - * Clear the set values on all properties of the {@link Commandlet#propertiesList} - */ - public void reset() { - - for (Property property : this.propertiesList) { - property.clearValue(); - } - } - - /** - * @param nameOrAlias the potential {@link Property#getName() name} or {@link Property#getAlias() alias} of the requested {@link Property}. - * @return the requested {@link Property property} or {@code null} if not found. - */ - public Property getOption(String nameOrAlias) { - - return this.optionMap.get(nameOrAlias); - } - - /** - * @param keyword the {@link KeywordProperty keyword} to {@link #add(Property) add}. - */ - protected void addKeyword(String keyword) { - - addKeyword(keyword, null); - } - - /** - * @param keyword the {@link KeywordProperty keyword} to {@link #add(Property) add}. - * @param alias the optional {@link KeywordProperty#getAlias() alias}. - */ - protected void addKeyword(String keyword, String alias) { - - KeywordProperty property = new KeywordProperty(keyword, true, alias); - if (this.firstKeyword == null) { - if (!this.properties.isEmpty()) { - throw new IllegalStateException(property + " must be first property in " + getClass().getSimpleName()); - } - this.firstKeyword = property; - } - add(property); - } - - /** - * @param

type of the {@link Property}. - * @param property the {@link Property} to register. - * @return the given {@link Property}. - */ - protected

> P add(P property) { - - if (this.multiValued != null) { - throw new IllegalStateException("The multi-valued property " + this.multiValued + " can not be followed by " + property); - } - this.propertiesList.add(property); - if (property.isOption()) { - add(property.getName(), property, false); - add(property.getAlias(), property, true); - } - if (property.isValue()) { - this.valuesList.add(property); - } - if (property.isMultiValued()) { - this.multiValued = property; - } - return property; - } - - private void add(String name, Property property, boolean alias) { - - if (alias && (name == null)) { - return; - } - Objects.requireNonNull(name); - assert (name.equals(name.trim())); - if (name.isEmpty() && !alias) { - return; - } - Property duplicate = this.optionMap.put(name, property); - if (duplicate != null) { - throw new IllegalStateException("Duplicate name or alias " + name + " for " + property + " and " + duplicate); - } - } +public interface Commandlet { /** - * @return the name of this {@link Commandlet} (e.g. "help"). + * @return the name of this {@link AbstractCommandlet} (e.g. "help"). */ - public abstract String getName(); + String getName(); /** - * @return the first keyword of this {@link Commandlet}. Typically, the same as {@link #getName() name} but may also differ (e.g. "set" vs. "set-version"). + * @return the {@link IdeContext} of this {@link Commandlet} or {@code null} for commandlets that are executed before the {@link IdeContext} has been + * initialized (e.g. {@code ContextCommandlet}). */ - public KeywordProperty getFirstKeyword() { + default IdeContext getContext() { - return this.firstKeyword; - } - - /** - * @param type of the {@link Commandlet}. - * @param commandletType the {@link Class} reflecting the requested {@link Commandlet}. - * @return the requested {@link Commandlet}. - * @see CommandletManager#getCommandlet(Class) - */ - protected C getCommandlet(Class commandletType) { - - return this.context.getCommandletManager().getCommandlet(commandletType); - } - - /** - * @return {@code true} if {@link IdeContext#getIdeHome() IDE_HOME} is required for this commandlet, {@code false} otherwise. - */ - public boolean isIdeHomeRequired() { - - return isIdeRootRequired(); - } - - /** - * @return {@code true} if {@link IdeContext#getIdeRoot() IDE_ROOT} is required for this commandlet, {@code false} otherwise. - */ - public boolean isIdeRootRequired() { - - return true; - } - - /** - * @return {@code true} to suppress the {@link com.devonfw.tools.ide.step.StepImpl#logSummary(boolean) step summary success message}. - */ - public boolean isSuppressStepSuccess() { - - return false; - } - - /** - * @return {@code true} if the output of this commandlet is (potentially) processed automatically from outside, {@code false} otherwise. For example - * {@link CompleteCommandlet} logs the suggestions for auto-completion to a bash script. Also, the {@link EnvironmentCommandlet} logs the environment - * variables for the {@code ide} wrapper script. In such scenarios these logs shall not be spammed with warnings like "IDE_ROOT is not set" that would - * break the processing of the output. - */ - public boolean isProcessableOutput() { - - return false; - } - - /** - * @return {@code true} to write a logfile (unless disabled via {@link com.devonfw.tools.ide.variable.IdeVariables#IDE_WRITE_LOGFILE}), {@code false} - * otherwise. - */ - public boolean isWriteLogFile() { - return !isProcessableOutput(); - } - - /** - * Runs this {@link Commandlet}. - */ - public final void run() { - - if (this.context != null) { // for ContextCommandlet we do not have a context yet - ((AbstractIdeContext) this.context).configureJavaUtilLogging(this); - } - doRun(); - } - - protected abstract void doRun(); - - /** - * @return {@code true} if this {@link Commandlet} is the valid candidate to be {@link #run()}, {@code false} otherwise. - * @see Property#validate() - */ - public ValidationResult validate() { - ValidationState state = new ValidationState(null); - // avoid validation exception if not a candidate to be run. - for (Property property : this.propertiesList) { - state.add(property.validate()); - } - return state; - } - - /** - * Provide additional usage help of this {@link Commandlet} to the user. - * - * @param bundle the {@link NlsBundle} to get I18N messages from. - */ - public void printHelp(NlsBundle bundle) { - - } - - @Override - public String toString() { - - return getClass().getSimpleName() + "[" + getName() + "]"; - } - - /** - * @return the {@link ToolCommandlet} set in a {@link Property} of this commandlet used for auto-completion of a {@link VersionIdentifier} or - * {@link com.devonfw.tools.ide.tool.plugin.ToolPluginDescriptor}, otherwise {@code null} if not exists or not configured. - */ - public ToolCommandlet getToolForCompletion() { return null; } + } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManager.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManager.java index dd6cc327..0b62ae29 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManager.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManager.java @@ -8,47 +8,47 @@ import com.devonfw.tools.ide.completion.CompletionCandidateCollector; import com.devonfw.tools.ide.property.KeywordProperty; import com.devonfw.tools.ide.property.Property; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; /** - * Interface to {@link #getCommandlet(Class) get} a {@link Commandlet} instance that is properly initialized. + * Interface to {@link #getCommandlet(Class) get} a {@link AbstractCommandlet} instance that is properly initialized. */ public interface CommandletManager { /** - * @param type of the {@link Commandlet}. - * @param commandletType the {@link Class} reflecting the requested {@link Commandlet}. - * @return the requested {@link Commandlet}. + * @param type of the {@link AbstractCommandlet}. + * @param commandletType the {@link Class} reflecting the requested {@link AbstractCommandlet}. + * @return the requested {@link AbstractCommandlet}. */ - C getCommandlet(Class commandletType); + C getCommandlet(Class commandletType); /** - * @param name the {@link Commandlet#getName() name} of the requested {@link Commandlet}. - * @return the requested {@link Commandlet} or {@code null} if not found. + * @param name the {@link AbstractCommandlet#getName() name} of the requested {@link AbstractCommandlet}. + * @return the requested {@link AbstractCommandlet} or {@code null} if not found. */ - Commandlet getCommandlet(String name); + AbstractCommandlet getCommandlet(String name); /** * @param keyword the first keyword argument. - * @return a {@link Commandlet} having the first {@link Property} {@link Property#isRequired() required} and a {@link KeywordProperty} with the given - * {@link Property#getName() name} or {@code null} if no such {@link Commandlet} is registered. + * @return a {@link AbstractCommandlet} having the first {@link Property} {@link Property#isRequired() required} and a {@link KeywordProperty} with the given + * {@link Property#getName() name} or {@code null} if no such {@link AbstractCommandlet} is registered. */ - Commandlet getCommandletByFirstKeyword(String keyword); + AbstractCommandlet getCommandletByFirstKeyword(String keyword); /** - * @return the {@link Collection} of all registered {@link Commandlet}s. + * @return the {@link Collection} of all registered {@link AbstractCommandlet}s. */ - Collection getCommandlets(); + Collection getCommandlets(); /** - * @param name the {@link Commandlet#getName() name} of the requested {@link Commandlet}. - * @return the requested {@link Commandlet}. + * @param name the {@link AbstractCommandlet#getName() name} of the requested {@link AbstractCommandlet}. + * @return the requested {@link AbstractCommandlet}. * @throws IllegalArgumentException if not found. */ - default Commandlet getRequiredCommandlet(String name) { + default AbstractCommandlet getRequiredCommandlet(String name) { - Commandlet commandlet = getCommandlet(name); + AbstractCommandlet commandlet = getCommandlet(name); if (commandlet == null) { throw new IllegalArgumentException("The commandlet " + name + " could not be found!"); } @@ -56,41 +56,41 @@ default Commandlet getRequiredCommandlet(String name) { } /** - * @param name the {@link Commandlet#getName() name} of the requested {@link ToolCommandlet}. - * @return the requested {@link ToolCommandlet} or {@code null} if not found. + * @param name the {@link AbstractCommandlet#getName() name} of the requested {@link AbstractToolCommandlet}. + * @return the requested {@link AbstractToolCommandlet} or {@code null} if not found. */ - default ToolCommandlet getToolCommandlet(String name) { + default AbstractToolCommandlet getToolCommandlet(String name) { - Commandlet commandlet = getCommandlet(name); - if (commandlet instanceof ToolCommandlet tc) { + AbstractCommandlet commandlet = getCommandlet(name); + if (commandlet instanceof AbstractToolCommandlet tc) { return tc; } return null; } /** - * @param name the {@link Commandlet#getName() name} of the requested {@link ToolCommandlet}. - * @return the requested {@link ToolCommandlet}. - * @throws IllegalArgumentException if no {@link ToolCommandlet} exists with the given {@code name}. + * @param name the {@link AbstractCommandlet#getName() name} of the requested {@link AbstractToolCommandlet}. + * @return the requested {@link AbstractToolCommandlet}. + * @throws IllegalArgumentException if no {@link AbstractToolCommandlet} exists with the given {@code name}. */ - default ToolCommandlet getRequiredToolCommandlet(String name) { + default AbstractToolCommandlet getRequiredToolCommandlet(String name) { - Commandlet commandlet = getRequiredCommandlet(name); - if (commandlet instanceof ToolCommandlet tc) { + AbstractCommandlet commandlet = getRequiredCommandlet(name); + if (commandlet instanceof AbstractToolCommandlet tc) { return tc; } throw new IllegalArgumentException("The commandlet " + name + " is not a ToolCommandlet!"); } /** - * @param name the {@link Commandlet#getName() name} of the requested {@link LocalToolCommandlet}. - * @return the requested {@link LocalToolCommandlet}. - * @throws IllegalArgumentException if no {@link LocalToolCommandlet} exists with the given {@code name}. + * @param name the {@link AbstractCommandlet#getName() name} of the requested {@link AbstractLocalToolCommandlet}. + * @return the requested {@link AbstractLocalToolCommandlet}. + * @throws IllegalArgumentException if no {@link AbstractLocalToolCommandlet} exists with the given {@code name}. */ - default LocalToolCommandlet getRequiredLocalToolCommandlet(String name) { + default AbstractLocalToolCommandlet getRequiredLocalToolCommandlet(String name) { - Commandlet commandlet = getRequiredCommandlet(name); - if (commandlet instanceof LocalToolCommandlet ltc) { + AbstractCommandlet commandlet = getRequiredCommandlet(name); + if (commandlet instanceof AbstractLocalToolCommandlet ltc) { return ltc; } throw new IllegalArgumentException("The commandlet " + name + " is not a LocalToolCommandlet!"); @@ -100,20 +100,20 @@ default LocalToolCommandlet getRequiredLocalToolCommandlet(String name) { * @param arguments the {@link CliArguments}. * @param collector the optional {@link CompletionCandidateCollector}. Will be {@code null} if no argument {@link CliArguments#isCompletion() completion} * shall be performed. - * @return an {@link Iterator} of the matching {@link Commandlet}(s). Typically empty or containing a single {@link Commandlet}. Only in edge-cases multiple - * {@link Commandlet}s could be found (e.g. if two {@link Commandlet}s exist with the same keyword but with different mandatory properties such as in our - * legacy devonfw-ide "ide get version ..." and "ide get edition ..." - however, we redesigned our CLI to "ide get-version ..." and "ide get-edition ..." - * to simplify this). + * @return an {@link Iterator} of the matching {@link AbstractCommandlet}(s). Typically empty or containing a single {@link AbstractCommandlet}. Only in + * edge-cases multiple {@link AbstractCommandlet}s could be found (e.g. if two {@link AbstractCommandlet}s exist with the same keyword but with different + * mandatory properties such as in our legacy devonfw-ide "ide get version ..." and "ide get edition ..." - however, we redesigned our CLI to "ide + * get-version ..." and "ide get-edition ..." to simplify this). */ - Iterator findCommandlet(CliArguments arguments, CompletionCandidateCollector collector); + Iterator findCommandlet(CliArguments arguments, CompletionCandidateCollector collector); /** - * Detects the applicable build tool for the given {@code buildPath} by {@link LocalToolCommandlet#findBuildDescriptor(Path) querying} the registered build - * commandlets (in order of priority) for a matching build descriptor (e.g. {@code pom.xml}, {@code build.gradle} or {@code package.json}). + * Detects the applicable build tool for the given {@code buildPath} by {@link AbstractLocalToolCommandlet#findBuildDescriptor(Path) querying} the registered + * build commandlets (in order of priority) for a matching build descriptor (e.g. {@code pom.xml}, {@code build.gradle} or {@code package.json}). * * @param buildPath the {@link Path} to the directory to build. - * @return the applicable build {@link LocalToolCommandlet} or {@code null} if no build descriptor was found or {@code buildPath} was {@code null}. + * @return the applicable build {@link AbstractLocalToolCommandlet} or {@code null} if no build descriptor was found or {@code buildPath} was {@code null}. */ - LocalToolCommandlet findBuildTool(Path buildPath); + AbstractLocalToolCommandlet findBuildTool(Path buildPath); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java index ad69d9fd..a3c308c6 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java @@ -22,7 +22,7 @@ import com.devonfw.tools.ide.git.repository.RepositoryCommandlet; import com.devonfw.tools.ide.property.KeywordProperty; import com.devonfw.tools.ide.property.Property; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.androidstudio.AndroidStudio; import com.devonfw.tools.ide.tool.aws.Aws; import com.devonfw.tools.ide.tool.az.Azure; @@ -88,17 +88,17 @@ public class CommandletManagerImpl implements CommandletManager { private static final Logger LOG = LoggerFactory.getLogger(CommandletManagerImpl.class); /** The build commandlets in order of priority - the first one with a matching build descriptor wins. */ - private static final List> BUILD_TOOLS = List.of(Mvn.class, Gradle.class, Yarn.class, Npm.class); + private static final List> BUILD_TOOLS = List.of(Mvn.class, Gradle.class, Yarn.class, Npm.class); private final IdeContext context; - private final Map, Commandlet> commandletTypeMap; + private final Map, AbstractCommandlet> commandletTypeMap; - private final Map commandletNameMap; + private final Map commandletNameMap; - private final Map firstKeywordMap; + private final Map firstKeywordMap; - private final Collection commandlets; + private final Collection commandlets; /** * The constructor. @@ -201,9 +201,9 @@ public CommandletManagerImpl(IdeContext context) { } /** - * @param commandlet the {@link Commandlet} to add. + * @param commandlet the {@link AbstractCommandlet} to add. */ - protected void add(Commandlet commandlet) { + protected void add(AbstractCommandlet commandlet) { boolean hasRequiredProperty = false; List> properties = commandlet.getProperties(); @@ -232,30 +232,30 @@ protected void add(Commandlet commandlet) { throw new IllegalStateException("Commandlet " + commandlet + " must have at least one mandatory property!"); } this.commandletTypeMap.put(commandlet.getClass(), commandlet); - Commandlet duplicate = this.commandletNameMap.put(commandlet.getName(), commandlet); + AbstractCommandlet duplicate = this.commandletNameMap.put(commandlet.getName(), commandlet); if (duplicate != null) { throw new IllegalStateException("Commandlet " + commandlet + " has the same name as " + duplicate); } } - private void registerKeyword(String keyword, Commandlet commandlet) { + private void registerKeyword(String keyword, AbstractCommandlet commandlet) { - Commandlet duplicate = this.firstKeywordMap.putIfAbsent(keyword, commandlet); + AbstractCommandlet duplicate = this.firstKeywordMap.putIfAbsent(keyword, commandlet); if (duplicate != null) { LOG.debug("Duplicate keyword {} already used by {} so it cannot be associated also with {}", keyword, duplicate, commandlet); } } @Override - public Collection getCommandlets() { + public Collection getCommandlets() { return this.commandlets; } @Override - public C getCommandlet(Class commandletType) { + public C getCommandlet(Class commandletType) { - Commandlet commandlet = this.commandletTypeMap.get(commandletType); + AbstractCommandlet commandlet = this.commandletTypeMap.get(commandletType); if (commandlet == null) { throw new IllegalStateException("Commandlet for type " + commandletType + " is not registered!"); } @@ -263,26 +263,26 @@ public C getCommandlet(Class commandletType) { } @Override - public Commandlet getCommandlet(String name) { + public AbstractCommandlet getCommandlet(String name) { return this.commandletNameMap.get(name); } @Override - public Commandlet getCommandletByFirstKeyword(String keyword) { + public AbstractCommandlet getCommandletByFirstKeyword(String keyword) { return this.firstKeywordMap.get(keyword); } @Override - public Iterator findCommandlet(CliArguments arguments, CompletionCandidateCollector collector) { + public Iterator findCommandlet(CliArguments arguments, CompletionCandidateCollector collector) { CliArgument current = arguments.current(); if (current.isEnd()) { return Collections.emptyIterator(); } String keyword = current.get(); - Commandlet commandlet = getCommandletByFirstKeyword(keyword); + AbstractCommandlet commandlet = getCommandletByFirstKeyword(keyword); if ((commandlet == null) && (collector == null)) { return Collections.emptyIterator(); } @@ -290,13 +290,13 @@ public Iterator findCommandlet(CliArguments arguments, CompletionCan } @Override - public LocalToolCommandlet findBuildTool(Path buildPath) { + public AbstractLocalToolCommandlet findBuildTool(Path buildPath) { if (buildPath == null) { return null; } - for (Class toolClass : BUILD_TOOLS) { - LocalToolCommandlet toolCommandlet = getCommandlet(toolClass); + for (Class toolClass : BUILD_TOOLS) { + AbstractLocalToolCommandlet toolCommandlet = getCommandlet(toolClass); if (toolCommandlet.findBuildDescriptor(buildPath) != null) { return toolCommandlet; } @@ -304,19 +304,19 @@ public LocalToolCommandlet findBuildTool(Path buildPath) { return null; } - private final class CommandletFinder implements Iterator { + private final class CommandletFinder implements Iterator { - private final Commandlet firstCandidate; + private final AbstractCommandlet firstCandidate; - private final Iterator commandletIterator; + private final Iterator commandletIterator; private final CliArguments arguments; private final CompletionCandidateCollector collector; - private Commandlet next; + private AbstractCommandlet next; - private CommandletFinder(Commandlet firstCandidate, CliArguments arguments, CompletionCandidateCollector collector) { + private CommandletFinder(AbstractCommandlet firstCandidate, CliArguments arguments, CompletionCandidateCollector collector) { this.firstCandidate = firstCandidate; this.commandletIterator = getCommandlets().iterator(); @@ -336,25 +336,25 @@ public boolean hasNext() { } @Override - public Commandlet next() { + public AbstractCommandlet next() { if (this.next == null) { throw new NoSuchElementException(); } - Commandlet result = this.next; + AbstractCommandlet result = this.next; this.next = findNext(); return result; } - private boolean isSuitable(Commandlet commandlet) { + private boolean isSuitable(AbstractCommandlet commandlet) { return (commandlet != null) && (!commandlet.isIdeHomeRequired() || (context.getIdeHome() != null)); } - private Commandlet findNext() { + private AbstractCommandlet findNext() { while (this.commandletIterator.hasNext()) { - Commandlet cmd = this.commandletIterator.next(); + AbstractCommandlet cmd = this.commandletIterator.next(); if ((cmd != this.firstCandidate) && isSuitable(cmd)) { List> properties = cmd.getProperties(); // validation should already be done in add method and could be removed here... diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CompleteCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CompleteCommandlet.java index 1655c272..bebd6023 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CompleteCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CompleteCommandlet.java @@ -13,9 +13,9 @@ import com.devonfw.tools.ide.property.StringProperty; /** - * {@link Commandlet} for auto-completion. + * {@link AbstractCommandlet} for auto-completion. */ -public final class CompleteCommandlet extends Commandlet { +public final class CompleteCommandlet extends AbstractCommandlet { /** The name of the complete commandlet. */ public static final String NAME = "complete"; diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/ContextCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/ContextCommandlet.java index da9903c3..95328434 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/ContextCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/ContextCommandlet.java @@ -11,7 +11,7 @@ /** * An internal pseudo-commandlet to create the {@link IdeContext}. It shall not be registered in {@link CommandletManager}. */ -public class ContextCommandlet extends Commandlet { +public class ContextCommandlet extends AbstractCommandlet { private final FlagProperty batch; diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CreateCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CreateCommandlet.java index b8681858..189b6b5f 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CreateCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CreateCommandlet.java @@ -15,7 +15,7 @@ import com.devonfw.tools.ide.version.IdeVersion; /** - * {@link Commandlet} to create a new IDEasy instance + * {@link AbstractCommandlet} to create a new IDEasy instance */ public class CreateCommandlet extends AbstractUpdateCommandlet { diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionGetCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionGetCommandlet.java index 981e6f66..c1b316c3 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionGetCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionGetCommandlet.java @@ -1,12 +1,12 @@ package com.devonfw.tools.ide.commandlet; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; /** - * An internal {@link Commandlet} to get the installed edition for a tool. + * An internal {@link AbstractCommandlet} to get the installed edition for a tool. * - * @see ToolCommandlet#getInstalledEdition() + * @see AbstractToolCommandlet#getInstalledEdition() */ public class EditionGetCommandlet extends AbstractVersionOrEditionGetCommandlet { @@ -33,13 +33,13 @@ protected String getPropertyToGet() { } @Override - protected Object getConfiguredValue(ToolCommandlet commandlet) { + protected Object getConfiguredValue(AbstractToolCommandlet commandlet) { return commandlet.getConfiguredEdition(); } @Override - protected Object getInstalledValue(ToolCommandlet commandlet) { + protected Object getInstalledValue(AbstractToolCommandlet commandlet) { return commandlet.getInstalledEdition(); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionListCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionListCommandlet.java index 43853171..49eb0ec7 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionListCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionListCommandlet.java @@ -2,14 +2,14 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.property.ToolProperty; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; /** - * An internal {@link Commandlet} to list editions for a tool. + * An internal {@link AbstractCommandlet} to list editions for a tool. * - * @see ToolCommandlet#listEditions() + * @see AbstractToolCommandlet#listEditions() */ -public class EditionListCommandlet extends Commandlet { +public class EditionListCommandlet extends AbstractCommandlet { /** The tool to list the editions of. */ public final ToolProperty tool; @@ -41,7 +41,7 @@ public boolean isWriteLogFile() { @Override protected void doRun() { - ToolCommandlet commandlet = this.tool.getValue(); + AbstractToolCommandlet commandlet = this.tool.getValue(); commandlet.listEditions(); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionSetCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionSetCommandlet.java index 08cbfdea..1ce50245 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionSetCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionSetCommandlet.java @@ -5,12 +5,12 @@ import com.devonfw.tools.ide.property.EditionProperty; import com.devonfw.tools.ide.property.EnumProperty; import com.devonfw.tools.ide.property.ToolProperty; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; /** - * An internal {@link Commandlet} to set a tool edition. + * An internal {@link AbstractCommandlet} to set a tool edition. */ -public class EditionSetCommandlet extends Commandlet { +public class EditionSetCommandlet extends AbstractCommandlet { /** The tool to set the edition of. */ public final ToolProperty tool; @@ -43,7 +43,7 @@ public String getName() { @Override protected void doRun() { - ToolCommandlet commandlet = this.tool.getValue(); + AbstractToolCommandlet commandlet = this.tool.getValue(); String edition = this.edition.getValue(); EnvironmentVariablesFiles env = this.cfg.getValue(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandlet.java index e4afe06b..ab31d4bb 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandlet.java @@ -18,12 +18,12 @@ import com.devonfw.tools.ide.os.WindowsPathSyntax; import com.devonfw.tools.ide.process.EnvironmentVariableCollectorContext; import com.devonfw.tools.ide.property.FlagProperty; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link Commandlet} to print the environment variables. + * {@link AbstractCommandlet} to print the environment variables. */ -public final class EnvironmentCommandlet extends Commandlet { +public final class EnvironmentCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(EnvironmentCommandlet.class); @@ -144,8 +144,8 @@ private String format(VariableLine line, boolean winCmd) { */ private void printBashCompletions() { - for (Commandlet commandlet : this.context.getCommandletManager().getCommandlets()) { - if (commandlet instanceof LocalToolCommandlet tool) { + for (AbstractCommandlet commandlet : this.context.getCommandletManager().getCommandlets()) { + if (commandlet instanceof AbstractLocalToolCommandlet tool) { try { if (tool.isInstalled()) { String bashCompletion = tool.getBashCompletion(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/HelpCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/HelpCommandlet.java index 1ffd249f..66a8a71e 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/HelpCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/HelpCommandlet.java @@ -18,9 +18,9 @@ import com.devonfw.tools.ide.version.IdeVersion; /** - * {@link Commandlet} to print the environment variables. + * {@link AbstractCommandlet} to print the environment variables. */ -public final class HelpCommandlet extends Commandlet { +public final class HelpCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(HelpCommandlet.class); @@ -63,7 +63,7 @@ protected void doRun() { this.context.printLogo(); NlsBundle bundle = NlsBundle.of(this.context); IdeLogLevel.SUCCESS.log(LOG, bundle.get("version-banner"), IdeVersion.getVersionString()); - Commandlet cmd = this.commandlet.getValue(); + AbstractCommandlet cmd = this.commandlet.getValue(); printCommandletHelp(bundle, cmd); if (cmd == null) { LOG.info(""); @@ -74,7 +74,7 @@ protected void doRun() { LOG.info(bundle.get("icd-hint")); } - private void printCommandletHelp(NlsBundle bundle, Commandlet cmd) { + private void printCommandletHelp(NlsBundle bundle, AbstractCommandlet cmd) { Args values = null; Args options = null; @@ -154,7 +154,7 @@ private void printCommandlets(NlsBundle bundle) { Args commandlets = new Args(); Args toolcommandlets = new Args(); - for (Commandlet cmd : this.context.getCommandletManager().getCommandlets()) { + for (AbstractCommandlet cmd : this.context.getCommandletManager().getCommandlets()) { String key = cmd.getName(); KeywordProperty keyword = cmd.getFirstKeyword(); if (keyword != null) { @@ -177,7 +177,7 @@ private void printCommandlets(NlsBundle bundle) { toolcommandlets.print(IdeLogLevel.INTERACTION); } - private void collectOptions(Args options, Commandlet cmd, NlsBundle bundle) { + private void collectOptions(Args options, AbstractCommandlet cmd, NlsBundle bundle) { for (Property property : cmd.getProperties()) { if (property.isOption() && !property.isRequired()) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/InstallCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/InstallCommandlet.java index 57ba14ba..568cc114 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/InstallCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/InstallCommandlet.java @@ -9,19 +9,19 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.property.ToolProperty; import com.devonfw.tools.ide.property.VersionProperty; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.IdeasyCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; import com.devonfw.tools.ide.tool.ToolEditionAndVersion; import com.devonfw.tools.ide.tool.ToolInstallRequest; import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.version.VersionIdentifier; /** - * {@link Commandlet} to install a tool. + * {@link AbstractCommandlet} to install a tool. * - * @see ToolCommandlet#install() + * @see AbstractToolCommandlet#install() */ -public class InstallCommandlet extends Commandlet { +public class InstallCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(InstallCommandlet.class); @@ -78,7 +78,7 @@ protected void doRun() { ideasy.setupWindowsTerminal(); return; } - ToolCommandlet commandlet = this.tool.getValue(); + AbstractToolCommandlet commandlet = this.tool.getValue(); VersionIdentifier versionIdentifier = this.version.getValue(); VersionIdentifier version = versionIdentifier; if (version == null) { @@ -97,7 +97,7 @@ protected void doRun() { } @Override - public ToolCommandlet getToolForCompletion() { + public AbstractToolCommandlet getToolForCompletion() { return this.tool.getValue(); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/InstallPluginCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/InstallPluginCommandlet.java index ac4b4662..616c300c 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/InstallPluginCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/InstallPluginCommandlet.java @@ -7,15 +7,15 @@ import com.devonfw.tools.ide.property.PluginProperty; import com.devonfw.tools.ide.property.ToolProperty; import com.devonfw.tools.ide.step.Step; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.plugin.PluginBasedCommandlet; /** - * {@link Commandlet} to install a tool. + * {@link AbstractCommandlet} to install a tool. * - * @see ToolCommandlet#install() + * @see AbstractToolCommandlet#install() */ -public class InstallPluginCommandlet extends Commandlet { +public class InstallPluginCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(InstallPluginCommandlet.class); @@ -46,7 +46,7 @@ public String getName() { @Override protected void doRun() { - ToolCommandlet commandlet = this.tool.getValue(); + AbstractToolCommandlet commandlet = this.tool.getValue(); String plugin = this.plugin.getValue(); if (commandlet instanceof PluginBasedCommandlet cmd) { @@ -59,7 +59,7 @@ protected void doRun() { } @Override - public ToolCommandlet getToolForCompletion() { + public AbstractToolCommandlet getToolForCompletion() { return this.tool.getValue(); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/LnCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/LnCommandlet.java index dd400be2..b78be129 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/LnCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/LnCommandlet.java @@ -13,12 +13,12 @@ import com.devonfw.tools.ide.property.PathProperty; /** - * // * Link creation {@link Commandlet} similar to {@code ln -s}. + * // * Link creation {@link AbstractCommandlet} similar to {@code ln -s}. *

* It tries to create a true symbolic link first. On Windows, symlink creation may be restricted due to missing privileges. In that case, IDEasy will create a * hard link as an alternative (file-only, same volume) to avoid the Git-Bash behavior of silently copying files. */ -public final class LnCommandlet extends Commandlet { +public final class LnCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(LnCommandlet.class); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/ReleaseCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/ReleaseCommandlet.java index 2c14eff0..31791878 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/ReleaseCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/ReleaseCommandlet.java @@ -10,14 +10,14 @@ import com.devonfw.tools.ide.git.GitContext; import com.devonfw.tools.ide.process.ProcessResult; import com.devonfw.tools.ide.property.StringProperty; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.BuildTool; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; import com.devonfw.tools.ide.version.VersionIdentifier; /** - * {@link Commandlet} to build and deploy a release of the current project. + * {@link AbstractCommandlet} to build and deploy a release of the current project. */ -public class ReleaseCommandlet extends Commandlet { +public class ReleaseCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(ReleaseCommandlet.class); @@ -42,7 +42,7 @@ protected void doRun() { Path projectPath = this.context.getCwd(); GitContext git = this.context.getGitContext(); - LocalToolCommandlet commandlet = this.context.getCommandletManager().findBuildTool(projectPath); + AbstractLocalToolCommandlet commandlet = this.context.getCommandletManager().findBuildTool(projectPath); if (commandlet == null) { throw new CliException("Could not find a build descriptor in " + projectPath + ". There is nothing to release here."); } @@ -102,7 +102,7 @@ private boolean warnIfFork(GitContext git, Path projectPath) { return false; } - private boolean isTopLevelProject(LocalToolCommandlet buildCommandlet, Path projectPath) { + private boolean isTopLevelProject(AbstractLocalToolCommandlet buildCommandlet, Path projectPath) { // top-level if the build descriptor found here is not also present in the parent directory Path parent = projectPath.getParent(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/RewriteCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/RewriteCommandlet.java index 7ece1bb5..1150f62a 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/RewriteCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/RewriteCommandlet.java @@ -17,9 +17,9 @@ import com.devonfw.tools.ide.tool.openrewrite.RewriteRecipeEnum; /** - * {@link Commandlet} for OpenRewrite refactoring. + * {@link AbstractCommandlet} for OpenRewrite refactoring. */ -public class RewriteCommandlet extends Commandlet { +public class RewriteCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(RewriteCommandlet.class); @@ -93,7 +93,7 @@ private boolean confirmApplyChange() { String input = this.context.askForInput( "***Before making actual changes to the code, please confirm it seriously." + " It is strongly recommended to perform a DRY-RUN first***\n" + - "Type yes to apply changes, or press other keys to perform DRY-RUN: "); + "Type yes to apply changes, or press other keys to perform DRY-RUN: "); return input.equalsIgnoreCase("yes"); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/ShellCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/ShellCommandlet.java index 01e93054..a4bffa90 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/ShellCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/ShellCommandlet.java @@ -30,9 +30,9 @@ import com.devonfw.tools.ide.property.Property; /** - * {@link Commandlet} for internal interactive shell with build-in auto-completion and help. + * {@link AbstractCommandlet} for internal interactive shell with build-in auto-completion and help. */ -public final class ShellCommandlet extends Commandlet { +public final class ShellCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(ShellCommandlet.class); @@ -126,8 +126,8 @@ protected void doRun() { /** * Strips the pre-filled {@link #IDE_PREFIX} from the given raw input line, so that a command entered after leaving the prefix untouched (e.g. - * {@code ide status}) behaves the same as if only {@code status} was entered. This allows the user to remove the prefix via backspace to enter a - * non-IDEasy command (e.g. {@code cd}) without the misleading {@code ide} prefix. + * {@code ide status}) behaves the same as if only {@code status} was entered. This allows the user to remove the prefix via backspace to enter a non-IDEasy + * command (e.g. {@code cd}) without the misleading {@code ide} prefix. * * @param rawLine the raw line as read from the {@link LineReader}. * @return the normalized line ready to be passed to {@link #runCommand(String)}. @@ -190,11 +190,12 @@ private int changeDirectory(CliArguments cliArgs) { /** * @param argument the current {@link CliArgument} (position) to match. - * @param commandlet the potential {@link Commandlet} to match. - * @return {@code true} if the given {@link Commandlet} matches to the given {@link CliArgument}(s) and those have been applied (set in the {@link Commandlet} - * and {@link Commandlet#validate() validated}), {@code false} otherwise (the {@link Commandlet} did not match and we have to try a different candidate). + * @param commandlet the potential {@link AbstractCommandlet} to match. + * @return {@code true} if the given {@link AbstractCommandlet} matches to the given {@link CliArgument}(s) and those have been applied (set in the + * {@link AbstractCommandlet} and {@link AbstractCommandlet#validate() validated}), {@code false} otherwise (the {@link AbstractCommandlet} did not match + * and we have to try a different candidate). */ - private boolean apply(CliArgument argument, Commandlet commandlet) { + private boolean apply(CliArgument argument, AbstractCommandlet commandlet) { LOG.trace("Trying to match arguments to commandlet {}", commandlet.getName()); CliArgument currentArgument = argument; diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/StatusCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/StatusCommandlet.java index 71fe06e0..97dc4d5e 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/StatusCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/StatusCommandlet.java @@ -18,9 +18,9 @@ import com.devonfw.tools.ide.version.VersionIdentifier; /** - * {@link Commandlet} to print a status report about IDEasy. + * {@link AbstractCommandlet} to print a status report about IDEasy. */ -public class StatusCommandlet extends Commandlet { +public class StatusCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(StatusCommandlet.class); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/TruststoreCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/TruststoreCommandlet.java index d7121a54..d8ac1f21 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/TruststoreCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/TruststoreCommandlet.java @@ -15,9 +15,9 @@ import com.devonfw.tools.ide.util.TruststoreUtil; /** - * {@link Commandlet} to fix the TLS problem for VPN users. + * {@link AbstractCommandlet} to fix the TLS problem for VPN users. */ -public class TruststoreCommandlet extends Commandlet { +public class TruststoreCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(TruststoreCommandlet.class); @@ -99,7 +99,8 @@ protected void doRun() { LOG.info("No truststore update is required for the given address."); if (defaultUrlUsed) { LOG.info( - "If the issue still occurs try to call the command again and add the url that is causing the problem to the command: \n ide fix-vpn-tls-problem "); + "If the issue still occurs try to call the command again and add the url that is causing the problem to the command: \n" + + " ide fix-vpn-tls-problem "); } return; @@ -116,7 +117,8 @@ protected void doRun() { } catch (Exception e) { LOG.error("Failed to capture certificate from {}:{}.", effectiveEndpoint.host(), effectiveEndpoint.port(), e); IdeLogLevel.INTERACTION.log(LOG, - "Please check proxy/VPN and retry. You can also follow: https://github.com/devonfw/IDEasy/blob/main/documentation/proxy-support.adoc#tls-certificate-issues"); + "Please check proxy/VPN and retry. You can also follow: " + + "https://github.com/devonfw/IDEasy/blob/main/documentation/proxy-support.adoc#tls-certificate-issues"); return; } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UninstallCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UninstallCommandlet.java index 3871d7da..a18a0114 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UninstallCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UninstallCommandlet.java @@ -5,13 +5,13 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.property.ToolProperty; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.IdeasyCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; /** - * An internal {@link Commandlet} to uninstall a tool. + * An internal {@link AbstractCommandlet} to uninstall a tool. */ -public class UninstallCommandlet extends Commandlet { +public class UninstallCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(UninstallCommandlet.class); @@ -57,7 +57,7 @@ protected void doRun() { return; } for (int i = 0; i < valueCount; i++) { - ToolCommandlet toolCommandlet = this.tools.getValue(i); + AbstractToolCommandlet toolCommandlet = this.tools.getValue(i); if (toolCommandlet.isInstalled()) { toolCommandlet.uninstall(); } else { diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UninstallPluginCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UninstallPluginCommandlet.java index fc9bf3b4..b38110cc 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UninstallPluginCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UninstallPluginCommandlet.java @@ -6,15 +6,15 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.property.PluginProperty; import com.devonfw.tools.ide.property.ToolProperty; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.plugin.PluginBasedCommandlet; /** - * {@link Commandlet} to install a tool. + * {@link AbstractCommandlet} to install a tool. * - * @see ToolCommandlet#install() + * @see AbstractToolCommandlet#install() */ -public class UninstallPluginCommandlet extends Commandlet { +public class UninstallPluginCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(UninstallPluginCommandlet.class); @@ -45,7 +45,7 @@ public String getName() { @Override protected void doRun() { - ToolCommandlet commandlet = this.tool.getValue(); + AbstractToolCommandlet commandlet = this.tool.getValue(); String plugin = this.plugin.getValue(); if (commandlet instanceof PluginBasedCommandlet cmd) { @@ -56,7 +56,7 @@ protected void doRun() { } @Override - public ToolCommandlet getToolForCompletion() { + public AbstractToolCommandlet getToolForCompletion() { return this.tool.getValue(); } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UnpackCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UnpackCommandlet.java index cf3251fe..7f6b6dc0 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UnpackCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UnpackCommandlet.java @@ -12,12 +12,12 @@ import com.devonfw.tools.ide.util.FilenameUtil; /** - * {@link Commandlet} to extract an archive file to a target directory. + * {@link AbstractCommandlet} to extract an archive file to a target directory. *

* Supports ZIP, TAR, TAR.GZ, TAR.BZ2, 7Z, JAR archives (cross-platform), as well as MSI (Windows) and DMG/PKG (Mac). *

*/ -public final class UnpackCommandlet extends Commandlet { +public final class UnpackCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(UnpackCommandlet.class); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UpgradeCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UpgradeCommandlet.java index d33d44af..31406c25 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UpgradeCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UpgradeCommandlet.java @@ -10,9 +10,9 @@ import com.devonfw.tools.ide.tool.ToolInstallation; /** - * {@link Commandlet} to upgrade the version of IDEasy + * {@link AbstractCommandlet} to upgrade the version of IDEasy */ -public class UpgradeCommandlet extends Commandlet { +public class UpgradeCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(UpgradeCommandlet.class); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UpgradeSettingsCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UpgradeSettingsCommandlet.java index 46914bb2..84c6b514 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UpgradeSettingsCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UpgradeSettingsCommandlet.java @@ -25,9 +25,9 @@ import com.devonfw.tools.ide.variable.VariableDefinition; /** - * {@link Commandlet} to upgrade settings after a migration from devonfw-ide to IDEasy. + * {@link AbstractCommandlet} to upgrade settings after a migration from devonfw-ide to IDEasy. */ -public class UpgradeSettingsCommandlet extends Commandlet { +public class UpgradeSettingsCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(UpgradeSettingsCommandlet.class); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionCommandlet.java index da3ecb2a..e2995343 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionCommandlet.java @@ -10,7 +10,7 @@ /** * Prints the IDEasy version and exits */ -public class VersionCommandlet extends Commandlet { +public class VersionCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(VersionCommandlet.class); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionGetCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionGetCommandlet.java index 8b9ddeea..c70a0618 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionGetCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionGetCommandlet.java @@ -1,12 +1,12 @@ package com.devonfw.tools.ide.commandlet; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; /** - * An internal {@link Commandlet} to get the installed version for a tool. + * An internal {@link AbstractCommandlet} to get the installed version for a tool. * - * @see ToolCommandlet#getInstalledVersion() + * @see AbstractToolCommandlet#getInstalledVersion() */ public class VersionGetCommandlet extends AbstractVersionOrEditionGetCommandlet { @@ -33,13 +33,13 @@ protected String getPropertyToGet() { } @Override - protected Object getConfiguredValue(ToolCommandlet commandlet) { + protected Object getConfiguredValue(AbstractToolCommandlet commandlet) { return commandlet.getConfiguredVersion(); } @Override - protected Object getInstalledValue(ToolCommandlet commandlet) { + protected Object getInstalledValue(AbstractToolCommandlet commandlet) { return commandlet.getInstalledVersion(); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionListCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionListCommandlet.java index abbb6fc7..359b19c4 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionListCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionListCommandlet.java @@ -2,14 +2,14 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.property.ToolProperty; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; /** - * An internal {@link Commandlet} to list versions for a tool. + * An internal {@link AbstractCommandlet} to list versions for a tool. * - * @see ToolCommandlet#listVersions() + * @see AbstractToolCommandlet#listVersions() */ -public class VersionListCommandlet extends Commandlet { +public class VersionListCommandlet extends AbstractCommandlet { /** The tool to list the versions of. */ public final ToolProperty tool; @@ -41,7 +41,7 @@ public boolean isWriteLogFile() { @Override protected void doRun() { - ToolCommandlet commandlet = this.tool.getValue(); + AbstractToolCommandlet commandlet = this.tool.getValue(); commandlet.listVersions(); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionSetCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionSetCommandlet.java index 1c12d1f4..6eb40932 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionSetCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionSetCommandlet.java @@ -5,15 +5,15 @@ import com.devonfw.tools.ide.property.EnumProperty; import com.devonfw.tools.ide.property.ToolProperty; import com.devonfw.tools.ide.property.VersionProperty; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.version.VersionIdentifier; /** - * An internal {@link Commandlet} to set a tool version. + * An internal {@link AbstractCommandlet} to set a tool version. * - * @see ToolCommandlet#setVersion(VersionIdentifier, boolean) + * @see AbstractToolCommandlet#setVersion(VersionIdentifier, boolean) */ -public class VersionSetCommandlet extends Commandlet { +public class VersionSetCommandlet extends AbstractCommandlet { /** The tool to set the version of. */ public final ToolProperty tool; @@ -46,14 +46,14 @@ public String getName() { @Override protected void doRun() { - ToolCommandlet commandlet = this.tool.getValue(); + AbstractToolCommandlet commandlet = this.tool.getValue(); VersionIdentifier versionIdentifier = this.version.getValue(); EnvironmentVariablesFiles env = this.cfg.getValue(); commandlet.setVersion(versionIdentifier, true, env); } @Override - public ToolCommandlet getToolForCompletion() { + public AbstractToolCommandlet getToolForCompletion() { return this.tool.getValue(); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/CheckCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/CheckCommandlet.java index 507f1dd5..c8c378ef 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/CheckCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/CheckCommandlet.java @@ -8,16 +8,16 @@ import org.slf4j.LoggerFactory; import com.devonfw.tools.ide.cli.CliException; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.git.GitContext; import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.property.FlagProperty; /** - * {@link Commandlet} to check the current repository for best-practices, starting from the current working directory (CWD). + * {@link AbstractCommandlet} to check the current repository for best-practices, starting from the current working directory (CWD). */ -public class CheckCommandlet extends Commandlet { +public class CheckCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(CheckCommandlet.class); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/CleanupCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/CleanupCommandlet.java index d086693a..7cb813bb 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/CleanupCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/CleanupCommandlet.java @@ -7,7 +7,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.step.Step; @@ -17,7 +17,7 @@ /** * Commandlet which scans your IDE installation for unused software (tools not currently used by any project) and removes them. */ -public class CleanupCommandlet extends Commandlet { +public class CleanupCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(CleanupCommandlet.class); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/AbstractUpdateCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/AbstractUpdateCommandlet.java index 96f525a5..0e3dfb7f 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/AbstractUpdateCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/AbstractUpdateCommandlet.java @@ -15,7 +15,7 @@ import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.cli.CliFatalException; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.commandlet.CommandletManager; import com.devonfw.tools.ide.commandlet.CreateCommandlet; import com.devonfw.tools.ide.commandlet.update.settings.SettingsHealthCheckResult; @@ -31,8 +31,8 @@ import com.devonfw.tools.ide.property.FlagProperty; import com.devonfw.tools.ide.property.StringProperty; import com.devonfw.tools.ide.step.Step; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.ToolEdition; import com.devonfw.tools.ide.tool.ToolEditionAndVersion; import com.devonfw.tools.ide.tool.ToolInstallRequest; @@ -45,9 +45,9 @@ import com.devonfw.tools.ide.version.VersionIdentifier; /** - * Abstract {@link Commandlet} base-class for both {@link UpdateCommandlet} and {@link CreateCommandlet}. + * Abstract {@link AbstractCommandlet} base-class for both {@link UpdateCommandlet} and {@link CreateCommandlet}. */ -public abstract class AbstractUpdateCommandlet extends Commandlet { +public abstract class AbstractUpdateCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(AbstractUpdateCommandlet.class); @@ -255,13 +255,13 @@ private void updateSoftware() { private void doUpdateSoftwareStep(Step step) { - Set toolCommandlets = new HashSet<>(); + Set toolCommandlets = new HashSet<>(); CommandletManager commandletManager = this.context.getCommandletManager(); // installed tools in IDE_HOME/software List softwarePaths = this.context.getFileAccess().listChildren(this.context.getSoftwarePath(), Files::isDirectory); for (Path softwarePath : softwarePaths) { String toolName = softwarePath.getFileName().toString(); - ToolCommandlet toolCommandlet = commandletManager.getToolCommandlet(toolName); + AbstractToolCommandlet toolCommandlet = commandletManager.getToolCommandlet(toolName); if (toolCommandlet != null) { toolCommandlets.add(toolCommandlet); } @@ -271,7 +271,7 @@ private void doUpdateSoftwareStep(Step step) { List regularTools = IdeVariables.IDE_TOOLS.get(this.context); if (regularTools != null) { for (String regularTool : regularTools) { - ToolCommandlet toolCommandlet = commandletManager.getToolCommandlet(regularTool); + AbstractToolCommandlet toolCommandlet = commandletManager.getToolCommandlet(regularTool); if (toolCommandlet == null) { String displayName = (regularTool == null || regularTool.isBlank()) ? "" : "'" + regularTool + "'"; LOG.error("Cannot install or update tool '{}''. No matching commandlet found. Please check your IDE_TOOLS configuration.", displayName); @@ -288,7 +288,7 @@ private void doUpdateSoftwareStep(Step step) { } // update/install the toolCommandlets - for (ToolCommandlet toolCommandlet : toolCommandlets) { + for (AbstractToolCommandlet toolCommandlet : toolCommandlets) { this.context.newStep("Install " + toolCommandlet.getName()).run(() -> toolCommandlet.install(false)); } @@ -309,7 +309,7 @@ private void installExtraToolInstallations(String tool, List property, Commandlet commandlet) { + Property property, AbstractCommandlet commandlet) { for (CompletionEntry entry : this.entryMap.values()) { entry.complete(arg, collector, property, commandlet); diff --git a/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollector.java b/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollector.java index b5e01c25..c64fc18b 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollector.java +++ b/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollector.java @@ -5,7 +5,7 @@ import java.util.Set; import java.util.stream.Collectors; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.property.Property; /** @@ -17,18 +17,18 @@ public interface CompletionCandidateCollector { * @param text the suggested word to add to auto-completion. * @param description the description of the suggestion candidate or {@code null} to determine automatically form the given parameters. * @param property the {@link Property} that triggered this suggestion. - * @param commandlet the {@link Commandlet} owning the {@link Property}. + * @param commandlet the {@link AbstractCommandlet} owning the {@link Property}. */ - void add(String text, String description, Property property, Commandlet commandlet); + void add(String text, String description, Property property, AbstractCommandlet commandlet); /** * @param text the suggested word to add to auto-completion. * @param description the description of the suggestion candidate or {@code null} to determine automatically form the given parameters. * @param property the {@link Property} that triggered this suggestion. - * @param commandlet the {@link Commandlet} owning the {@link Property}. + * @param commandlet the {@link AbstractCommandlet} owning the {@link Property}. * @return the {@link CompletionCandidate} for the given parameters. */ - default CompletionCandidate createCandidate(String text, String description, Property property, Commandlet commandlet) { + default CompletionCandidate createCandidate(String text, String description, Property property, AbstractCommandlet commandlet) { if (description == null) { // compute description from property + commandlet like in HelpCommandlet? @@ -41,10 +41,10 @@ default CompletionCandidate createCandidate(String text, String description, Pro * @param text the suggested word to add to auto-completion. * @param sortedCandidates the array of candidates sorted in ascending order. * @param property the {@link Property} that triggered this suggestion. - * @param commandlet the {@link Commandlet} owning the {@link Property}. + * @param commandlet the {@link AbstractCommandlet} owning the {@link Property}. * @return the number of {@link CompletionCandidate}s that have been added. */ - default int addAllMatches(String text, String[] sortedCandidates, Property property, Commandlet commandlet) { + default int addAllMatches(String text, String[] sortedCandidates, Property property, AbstractCommandlet commandlet) { if (text.isEmpty()) { for (String candidate : sortedCandidates) { @@ -64,7 +64,7 @@ default int addAllMatches(String text, String[] sortedCandidates, Property pr } /** - * Resets this {@link CompletionCandidateCollector} to reuse trying the next {@link Commandlet}. + * Resets this {@link CompletionCandidateCollector} to reuse trying the next {@link AbstractCommandlet}. */ default void clear() { diff --git a/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollectorAdapter.java b/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollectorAdapter.java index 1e28cb60..d3446796 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollectorAdapter.java +++ b/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollectorAdapter.java @@ -3,7 +3,7 @@ import java.util.List; import java.util.Set; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.property.Property; /** @@ -29,7 +29,7 @@ public CompletionCandidateCollectorAdapter(String prefix, CompletionCandidateCol } @Override - public void add(String text, String description, Property property, Commandlet commandlet) { + public void add(String text, String description, Property property, AbstractCommandlet commandlet) { this.delegate.add(this.prefix + text, description, property, commandlet); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollectorDefault.java b/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollectorDefault.java index 4636f1b7..1e98a96a 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollectorDefault.java +++ b/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollectorDefault.java @@ -9,7 +9,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.property.Property; @@ -56,7 +56,7 @@ public CompletionCandidateCollectorDefault(IdeContext context, Set alrea } @Override - public void add(String text, String description, Property property, Commandlet commandlet) { + public void add(String text, String description, Property property, AbstractCommandlet commandlet) { // Check if this candidate already exists to avoid duplicates for (CompletionCandidate existing : this.candidates) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionEntry.java b/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionEntry.java index ef839103..b68166fc 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionEntry.java +++ b/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionEntry.java @@ -4,7 +4,7 @@ import java.util.List; import java.util.Set; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.property.Property; /** @@ -44,11 +44,11 @@ public String getCandidate() { * Performs auto-completion for this entry, skipping it entirely if the candidate or any of its alternatives has already been provided on the command line. * * @param arg the current argument being completed. - * @param collector the {@link CompletionCandidateCollector} to add the candidates to. - * @param property the {@link Property} that triggered the completion. - * @param commandlet the {@link Commandlet} owning the property. + * @param collector the {@link CompletionCandidateCollector} to add matching candidates to. + * @param property the {@link Property} that triggered completion. + * @param commandlet the {@link AbstractCommandlet} owning the property. */ - public void complete(String arg, CompletionCandidateCollector collector, Property property, Commandlet commandlet) { + public void complete(String arg, CompletionCandidateCollector collector, Property property, AbstractCommandlet commandlet) { Set alreadyProvided = collector.getAlreadyProvided(); if (!isDependencySatisfied(alreadyProvided)) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java index ec076f07..23bf4d83 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java @@ -32,7 +32,7 @@ import com.devonfw.tools.ide.cli.CliArguments; import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.cli.CliSuggester; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.commandlet.CommandletManager; import com.devonfw.tools.ide.commandlet.CommandletManagerImpl; import com.devonfw.tools.ide.commandlet.ContextCommandlet; @@ -74,7 +74,7 @@ import com.devonfw.tools.ide.property.Property; import com.devonfw.tools.ide.step.Step; import com.devonfw.tools.ide.step.StepImpl; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.tool.custom.CustomToolRepository; import com.devonfw.tools.ide.tool.custom.CustomToolRepositoryImpl; @@ -977,8 +977,8 @@ public void setEnvironmentOfInstalledTools(EnvironmentContext environmentContext if (getSoftwarePath() == null) { return; } - for (Commandlet commandlet : getCommandletManager().getCommandlets()) { - if (commandlet instanceof LocalToolCommandlet tool) { + for (AbstractCommandlet commandlet : getCommandletManager().getCommandlets()) { + if (commandlet instanceof AbstractLocalToolCommandlet tool) { Path toolPath = tool.getToolPath(); // we cannot use isInstalled() here since it may spawn processes (e.g. "npm --version") what would be way too expensive. if ((toolPath != null) && Files.isDirectory(toolPath)) { @@ -1283,7 +1283,8 @@ public void endStep(StepImpl step) { } /** - * Finds the matching {@link Commandlet} to run, applies {@link CliArguments} to its {@link Commandlet#getProperties() properties} and will execute it. + * Finds the matching {@link AbstractCommandlet} to run, applies {@link CliArguments} to its {@link AbstractCommandlet#getProperties() properties} and will + * execute it. * * @param arguments the {@link CliArgument}. * @return the return code of the execution. @@ -1298,8 +1299,8 @@ public int run(CliArguments arguments) { assert (this.currentStep == null); boolean supressStepSuccess = false; StepImpl step = newStep(true, "ide", (Object[]) current.asArray()); - Iterator commandletIterator = this.commandletManager.findCommandlet(arguments, null); - Commandlet cmd = null; + Iterator commandletIterator = this.commandletManager.findCommandlet(arguments, null); + AbstractCommandlet cmd = null; ValidationResult result = null; try { while (commandletIterator.hasNext()) { @@ -1317,7 +1318,7 @@ public int run(CliArguments arguments) { if (commandKey == null || commandKey.isBlank()) { return 0; } - Commandlet commandletByName = this.commandletManager.getCommandlet(commandKey); + AbstractCommandlet commandletByName = this.commandletManager.getCommandlet(commandKey); // Missing commandlet if (commandletByName == null) { if (getCliSuggester().isMissingCommandletHandled(commandKey, step)) { @@ -1384,7 +1385,7 @@ private CliSuggester getCliSuggester() { /** * Ensure the logging system is initialized. */ - private void activateLogging(Commandlet cmd) { + private void activateLogging(AbstractCommandlet cmd) { configureJavaUtilLogging(cmd); this.startContext.activateLogging(); @@ -1393,9 +1394,9 @@ private void activateLogging(Commandlet cmd) { /** * Configures the logging system (JUL). * - * @param cmd the {@link Commandlet} to be called. May be {@code null}. + * @param cmd the {@link AbstractCommandlet} to be called. May be {@code null}. */ - public void configureJavaUtilLogging(Commandlet cmd) { + public void configureJavaUtilLogging(AbstractCommandlet cmd) { if (this.julConfigured) { return; @@ -1416,7 +1417,7 @@ public void configureJavaUtilLogging(Commandlet cmd) { } } - protected boolean isWriteLogfile(Commandlet cmd) { + protected boolean isWriteLogfile(AbstractCommandlet cmd) { if ((cmd == null) || !cmd.isWriteLogFile()) { return false; } @@ -1424,7 +1425,7 @@ protected boolean isWriteLogfile(Commandlet cmd) { return Boolean.TRUE.equals(writeLogfile); } - private Properties createJavaUtilLoggingProperties(boolean writeLogfile, Commandlet cmd) { + private Properties createJavaUtilLoggingProperties(boolean writeLogfile, AbstractCommandlet cmd) { Path idePath = getIdePath(); if (writeLogfile && (idePath == null)) { @@ -1451,7 +1452,7 @@ private Properties createJavaUtilLoggingProperties(boolean writeLogfile, Command return properties; } - private Path createLogfilePath(Path idePath, Commandlet cmd) { + private Path createLogfilePath(Path idePath, AbstractCommandlet cmd) { LocalDateTime now = LocalDateTime.now(); Path logsPath = idePath.resolve(FOLDER_LOGS).resolve(DateTimeUtil.formatDate(now, true)); StringBuilder sb = new StringBuilder(32); @@ -1480,11 +1481,12 @@ public void runWithoutLogging(Runnable lambda, IdeLogLevel threshold) { } /** - * @param cmd the potential {@link Commandlet} to {@link #apply(CliArguments, Commandlet) apply} and {@link Commandlet#run() run}. - * @return {@code true} if the given {@link Commandlet} matched and did {@link Commandlet#run() run} successfully, {@code false} otherwise (the - * {@link Commandlet} did not match and we have to try a different candidate). + * @param cmd the potential {@link AbstractCommandlet} to {@link #apply(CliArguments, AbstractCommandlet) apply} and + * {@link AbstractCommandlet#run() run}. + * @return {@code true} if the given {@link AbstractCommandlet} matched and did {@link AbstractCommandlet#run() run} successfully, {@code false} otherwise + * (the {@link AbstractCommandlet} did not match and we have to try a different candidate). */ - private ValidationResult applyAndRun(CliArguments arguments, Commandlet cmd) { + private ValidationResult applyAndRun(CliArguments arguments, AbstractCommandlet cmd) { IdeLogLevel previousLogLevel = null; cmd.reset(); @@ -1548,10 +1550,10 @@ settingsRepository, getSettingsCommitIdPath()))) { * determines the correct message to log, depending on whether the settings repository is a symlink/junction, or not. Should the user already be running the * appropriate {@code ide update} command, the message is suppressed to avoid confusion. * - * @param cmd the {@link Commandlet}. + * @param cmd the {@link AbstractCommandlet}. * @return {@code msg} to log to the console. {@code null} if the message is suppressed. */ - private String determineSettingsUpdateMessage(Commandlet cmd) { + private String determineSettingsUpdateMessage(AbstractCommandlet cmd) { boolean update = cmd instanceof UpdateCommandlet; RepositoryType settingsRepositoryType = RepositoryType.ofSettingsPath(getSettingsPath(), this); if (settingsRepositoryType == RepositoryType.CODE_SETTINGS_COMBINED) { @@ -1567,7 +1569,7 @@ private String determineSettingsUpdateMessage(Commandlet cmd) { } } - private boolean ensureLicenseAgreement(Commandlet cmd) { + private boolean ensureLicenseAgreement(AbstractCommandlet cmd) { if (isTest()) { return true; // ignore for tests @@ -1662,14 +1664,14 @@ public List complete(CliArguments arguments, CompletionCand property.apply(arguments, this, cc, collector); } } - Iterator commandletIterator = this.commandletManager.findCommandlet(arguments, collector); + Iterator commandletIterator = this.commandletManager.findCommandlet(arguments, collector); CliArgument current = arguments.current(); if (current.isCompletion() && current.isCombinedShortOption()) { collector.add(current.get(), null, null, null); } arguments.next(); while (commandletIterator.hasNext()) { - Commandlet cmd = commandletIterator.next(); + AbstractCommandlet cmd = commandletIterator.next(); if (!arguments.current().isEnd()) { completeCommandlet(arguments.copy(), cmd, collector); } @@ -1698,7 +1700,7 @@ private Property nextValueProperty(Iterator> valueIterator, CliAr return valueProperty; } - private void completeCommandlet(CliArguments arguments, Commandlet cmd, CompletionCandidateCollector collector) { + private void completeCommandlet(CliArguments arguments, AbstractCommandlet cmd, CompletionCandidateCollector collector) { LOG.trace("Trying to match arguments for auto-completion for commandlet {}", cmd.getName()); @@ -1763,10 +1765,10 @@ private void completeCommandlet(CliArguments arguments, Commandlet cmd, Completi /** * @param arguments the {@link CliArguments} to apply. Will be {@link CliArguments#next() consumed} as they are matched. Consider passing a * {@link CliArguments#copy() copy} as needed. - * @param cmd the potential {@link Commandlet} to match. + * @param cmd the potential {@link AbstractCommandlet} to match. * @return the {@link ValidationResult} telling if the {@link CliArguments} can be applied successfully or if validation errors ocurred. */ - public ValidationResult apply(CliArguments arguments, Commandlet cmd) { + public ValidationResult apply(CliArguments arguments, AbstractCommandlet cmd) { LOG.trace("Trying to match arguments to commandlet {}", cmd.getName()); CliArgument currentArgument = arguments.current(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java index 34f142c7..d848aef2 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java @@ -10,6 +10,7 @@ import com.devonfw.tools.ide.cli.CliAbortException; import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.cli.CliOfflineException; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.commandlet.CommandletManager; import com.devonfw.tools.ide.commandlet.update.AbstractUpdateCommandlet; import com.devonfw.tools.ide.common.SystemPath; @@ -31,6 +32,7 @@ import com.devonfw.tools.ide.tool.corepack.Corepack; import com.devonfw.tools.ide.tool.custom.CustomToolRepository; import com.devonfw.tools.ide.tool.gradle.Gradle; +import com.devonfw.tools.ide.tool.ide.AbstractIdeToolCommandlet; import com.devonfw.tools.ide.tool.mvn.Mvn; import com.devonfw.tools.ide.tool.mvn.MvnRepository; import com.devonfw.tools.ide.tool.npm.Npm; @@ -49,7 +51,7 @@ * referenced instead of duplicating such string literals across the code-base. All central components can be accessed from here such as: *