diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc
index 5a5f51ce30..8447ef58f8 100644
--- a/CHANGELOG.adoc
+++ b/CHANGELOG.adoc
@@ -9,6 +9,7 @@ Release with new features and bugfixes:
* https://github.com/devonfw/IDEasy/issues/2546[#2546]: Include repository into git warning/errors
* https://github.com/devonfw/IDEasy/issues/2293[#2293]: Rework spyder to have same features as IdeToolCommandlets
* https://github.com/devonfw/IDEasy/issues/871[#871]: Add progressbar to *.dmg extract process
+* https://github.com/devonfw/IDEasy/issues/1167[#1167]: Automatic project import for vs code
* https://github.com/devonfw/IDEasy/issues/2345[#2345]: DOCKER_EDITION ignored on Linux
* https://github.com/devonfw/IDEasy/issues/2381[#2381]: Make node and npm pristine versioned tools with per-project npm global prefix
* https://github.com/devonfw/IDEasy/issues/2607[#2607]: Align tool help for corepack
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 cab23ba111..b0ab6f22b3 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
@@ -1,11 +1,20 @@
package com.devonfw.tools.ide.tool.ide;
+import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
+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.environment.AbstractEnvironmentVariables;
+import com.devonfw.tools.ide.environment.EnvironmentVariables;
+import com.devonfw.tools.ide.environment.ExtensibleEnvironmentVariables;
+import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet;
+import com.devonfw.tools.ide.tool.BuildTool;
import com.devonfw.tools.ide.tool.LocalToolCommandlet;
/**
@@ -16,6 +25,8 @@
*/
public interface IdeToolCommandlet extends LocalToolCommandlet {
+ Logger LOG = LoggerFactory.getLogger(IdeToolCommandlet.class);
+
/**
* @return the {@link IdeWorkspaceConfigurer} that configures the workspace of this IDE. Needed so that the default {@link #configureWorkspace()} of this
* interface can delegate the shared workspace configuration logic to a single implementation.
@@ -61,10 +72,109 @@ default Path getIdeMetadataPath() {
/**
* Imports the repository specified by the given {@link Path} into the IDE managed by this {@link IdeToolCommandlet}.
*
+ *
+ * The repository is searched for a build descriptor of any build tool that this IDE supports via {@link #getBuildTool2TemplateMap()}. The first match
+ * triggers a merge of the corresponding template into the workspace via {@link #mergeTemplate(Path, String)}. If no build tool of this IDE applies the
+ * repository is skipped.
+ *
+ *
* @param repositoryPath the {@link Path} to the repository directory to import.
*/
default void importRepository(Path repositoryPath) {
- throw new CliException("Repository import is not yet supported for IDE " + getName());
+ BuildTool buildTool = getContext().getCommandletManager().findBuildTool(repositoryPath);
+ String templateFilename = (buildTool == null) ? null : getBuildTool2TemplateMap().get(buildTool.getClass());
+ if (templateFilename == null) {
+ LOG.warn("No supported build descriptor was found for project import in {}", repositoryPath);
+ return;
+ }
+ LOG.debug("Found build tool {} so merging template {}", buildTool.getName(), templateFilename);
+ mergeTemplate(repositoryPath, templateFilename);
+ }
+
+ /**
+ * @return the mapping of supported build tool commandlets to the template file name to be merged into the workspace (see
+ * {@link #mergeTemplate(Path, String)}) when the corresponding build descriptor is present in the imported repository.
+ * The default is an empty map meaning that no build tool is supported for repository import by this IDE.
+ */
+ default Map, String> getBuildTool2TemplateMap() {
+
+ return Map.of();
+ }
+
+ /**
+ * Merges the template with the given file name into the workspace for the imported repository. This is the IDE-specific part of
+ * {@link #importRepository(Path)} and is called after a supported build descriptor was found.
+ *
+ *
+ * The template location is built dynamically from the tool name (see {@link #getTemplateFolder()}) and the template file name, so no per-IDE template path
+ * constant is needed. The environment variables are created via {@link #getTemplateEnvironmentVariables(Path)} with the relative project path as
+ * {@code PROJECT_PATH}.
+ *
+ *
+ * @param repositoryPath the {@link Path} to the imported repository directory.
+ * @param templateFilename the file name of the workspace-relative template to merge (as configured in {@link #getBuildTool2TemplateMap()}).
+ */
+ default void mergeTemplate(Path repositoryPath, String templateFilename) {
+
+ String templateFolder = getTemplateFolder();
+ if (templateFolder == null) {
+ throw new UnsupportedOperationException("Repository import is not yet implemented for IDE " + getName());
+ }
+ Path templateFile = getContext().getSettingsPath()
+ .resolve(getName())
+ .resolve(IdeContext.FOLDER_WORKSPACE)
+ .resolve(IdeContext.FOLDER_REPOSITORY)
+ .resolve(templateFolder)
+ .resolve(templateFilename);
+ if (!Files.exists(templateFile)) {
+ throw new CliException("Cannot import project into workspace: template file not found at " + templateFile + "\n"
+ + "Please do an upstream merge of your settings git repository.");
+ }
+ Path workspacesPath = getContext().getIdeHome().resolve(IdeContext.FOLDER_WORKSPACES);
+ Path workspacePath = getContext().getFileAccess().findAncestor(repositoryPath, workspacesPath, 1);
+ if (workspacePath == null) {
+ throw new CliException("Cannot import project into workspace: could not find workspace from " + repositoryPath);
+ }
+ EnvironmentVariables environmentVariables = getTemplateEnvironmentVariables(workspacePath.relativize(repositoryPath));
+ Path workspaceFile = workspacePath.resolve(templateFolder).resolve(templateFilename);
+ doMergeTemplate(templateFile, workspaceFile, environmentVariables);
+ }
+
+ /**
+ * Performs the actual merge of the resolved template file into the workspace file. This is the only IDE-specific part of
+ * {@link #mergeTemplate(Path, String)} as the merge algorithm differs per IDE (e.g. {@code JSON} vs {@code XML}).
+ *
+ * @param templateFile the resolved {@link Path} to the template file in the settings repository.
+ * @param workspaceFile the {@link Path} to the target file in the workspace to merge the template into.
+ * @param environmentVariables the {@link EnvironmentVariables} to resolve variables (e.g. {@code PROJECT_PATH}) in the template.
+ */
+ default void doMergeTemplate(Path templateFile, Path workspaceFile, EnvironmentVariables environmentVariables) {
+
+ throw new UnsupportedOperationException("Repository import is not yet implemented for IDE " + getName());
+ }
+
+ /**
+ * @return the name of the IDE configuration folder (e.g. {@code .vscode} or {@code .idea}) inside which the repository workspace templates
+ * are stored and merged, or {@code null} if this IDE does not support repository import. This folder is used both in the settings
+ * repository to locate the template and in the workspace to store the merged result.
+ */
+ default String getTemplateFolder() {
+
+ return null;
+ }
+
+ /**
+ * Creates {@link EnvironmentVariables} for resolving the imported repository workspace template with the relative project path as {@code PROJECT_PATH}.
+ *
+ * @param projectPath the relative {@link Path} from the workspace root to the repository.
+ * @return the resolved {@link EnvironmentVariables}.
+ */
+ default EnvironmentVariables getTemplateEnvironmentVariables(Path projectPath) {
+
+ ExtensibleEnvironmentVariables environmentVariables = new ExtensibleEnvironmentVariables(
+ (AbstractEnvironmentVariables) getContext().getVariables().getParent(), getContext());
+ environmentVariables.setValue("PROJECT_PATH", projectPath.toString().replace('\\', '/'));
+ return environmentVariables.resolved();
}
}
diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/intellij/Intellij.java b/cli/src/main/java/com/devonfw/tools/ide/tool/intellij/Intellij.java
index 5bfff290e2..ea4ce60b8c 100644
--- a/cli/src/main/java/com/devonfw/tools/ide/tool/intellij/Intellij.java
+++ b/cli/src/main/java/com/devonfw/tools/ide/tool/intellij/Intellij.java
@@ -9,16 +9,13 @@
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
-import com.devonfw.tools.ide.cli.CliException;
import com.devonfw.tools.ide.common.Tag;
import com.devonfw.tools.ide.context.IdeContext;
-import com.devonfw.tools.ide.environment.AbstractEnvironmentVariables;
import com.devonfw.tools.ide.environment.EnvironmentVariables;
-import com.devonfw.tools.ide.environment.ExtensibleEnvironmentVariables;
import com.devonfw.tools.ide.merge.xml.XmlMergeDocument;
import com.devonfw.tools.ide.merge.xml.XmlMerger;
import com.devonfw.tools.ide.process.EnvironmentContext;
-import com.devonfw.tools.ide.tool.BuildTool;
+import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet;
import com.devonfw.tools.ide.tool.ToolEdition;
import com.devonfw.tools.ide.tool.ToolEditionAndVersion;
import com.devonfw.tools.ide.tool.ToolInstallation;
@@ -43,12 +40,11 @@ public class Intellij extends IdeaBasedIdeToolCommandlet {
private static final String IDEA_BASH_SCRIPT = IDEA + ".sh";
private static final String FOLDER_IDEA_CONFIG = ".idea";
- private static final String TEMPLATE_LOCATION = "intellij/workspace/repository/" + FOLDER_IDEA_CONFIG;
private static final String GRADLE_XML = "gradle.xml";
private static final String MISC_XML = "misc.xml";
private static final String IDEA_PROPERTIES = "idea.properties";
- private static final Map, String> BUILD_TOOL_TO_IJ_TEMPLATE = Map.of(Mvn.class, MISC_XML, Gradle.class, GRADLE_XML);
+ private static final Map, String> BUILD_TOOL_TO_IJ_TEMPLATE = Map.of(Mvn.class, MISC_XML, Gradle.class, GRADLE_XML);
/**
* The constructor.
@@ -122,32 +118,23 @@ protected ToolEditionAndVersion adjustRequestedEdition(ToolEditionAndVersion req
return requested;
}
- private EnvironmentVariables getIntellijEnvironmentVariables(Path projectPath) {
- ExtensibleEnvironmentVariables environmentVariables = new ExtensibleEnvironmentVariables(
- (AbstractEnvironmentVariables) this.context.getVariables().getParent(), this.context);
+ @Override
+ public String getTemplateFolder() {
- environmentVariables.setValue("PROJECT_PATH", projectPath.toString().replace('\\', '/'));
- return environmentVariables.resolved();
+ return FOLDER_IDEA_CONFIG;
}
- private void mergeConfig(Path repositoryPath, String configFilePath) {
- Path templatePath = this.context.getSettingsPath().resolve(TEMPLATE_LOCATION);
- Path templateFile = templatePath.resolve(configFilePath);
- if (!Files.exists(templateFile)) {
- throw new CliException(
- "Cannot import project into workspace: template file not found at " + templateFile + "\n"
- + "Please do an upstream merge of your settings git repository.");
- }
- Path workspacesPath = this.context.getIdeHome().resolve(IdeContext.FOLDER_WORKSPACES);
- Path workspacePath = this.context.getFileAccess().findAncestor(repositoryPath, workspacesPath, 1);
- if (workspacePath == null) {
- throw new CliException(
- "Cannot import project into workspace: could not find workspace from " + repositoryPath);
- }
- XmlMerger xmlMerger = new XmlMerger(this.context);
- EnvironmentVariables environmentVariables = getIntellijEnvironmentVariables(workspacePath.relativize(repositoryPath));
- Path workspaceFile = workspacePath.resolve(FOLDER_IDEA_CONFIG).resolve(configFilePath);
+ /**
+ * Merges the IntelliJ project template into the workspace's {@code .idea} config (e.g. {@code misc.xml} or {@code gradle.xml}).
+ *
+ * @param templateFile the resolved {@link Path} to the template file in the settings repository.
+ * @param workspaceFile the {@link Path} to the target {@code .idea} file to merge into.
+ * @param environmentVariables the {@link EnvironmentVariables} to resolve variables (e.g. {@code PROJECT_PATH}) in the template.
+ */
+ @Override
+ public void doMergeTemplate(Path templateFile, Path workspaceFile, EnvironmentVariables environmentVariables) {
+ XmlMerger xmlMerger = new XmlMerger(this.context);
XmlMergeDocument workspaceDocument = xmlMerger.load(workspaceFile);
XmlMergeDocument templateDocument = xmlMerger.loadAndResolve(templateFile, environmentVariables);
@@ -157,14 +144,8 @@ private void mergeConfig(Path repositoryPath, String configFilePath) {
}
@Override
- public void importRepository(Path repositoryPath) {
- BuildTool buildTool = this.context.getCommandletManager().findBuildTool(repositoryPath);
- String templateFilename = (buildTool == null) ? null : BUILD_TOOL_TO_IJ_TEMPLATE.get(buildTool.getClass());
- if (templateFilename == null) {
- LOG.warn("No supported build descriptor was found for project import in {}", repositoryPath);
- return;
- }
- LOG.debug("Found build tool {} so merging template {}", buildTool.getName(), templateFilename);
- mergeConfig(repositoryPath, templateFilename);
+ public Map, String> getBuildTool2TemplateMap() {
+
+ return BUILD_TOOL_TO_IJ_TEMPLATE;
}
}
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 ac461b56e7..8d4b16629e 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,5 +1,6 @@
package com.devonfw.tools.ide.tool.vscode;
+import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -12,15 +13,24 @@
import com.devonfw.tools.ide.common.Tag;
import com.devonfw.tools.ide.context.IdeContext;
+import com.devonfw.tools.ide.environment.EnvironmentVariables;
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;
import com.devonfw.tools.ide.process.ProcessResult;
import com.devonfw.tools.ide.step.Step;
+import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet;
+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.plugin.ToolPluginDescriptor;
import com.devonfw.tools.ide.variable.IdeVariables;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.databind.node.TextNode;
/**
* {@link AbstractToolCommandlet} for vscode.
@@ -35,6 +45,25 @@ public class Vscode extends AbstractIdeToolCommandlet {
/** 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");
+ /**
+ * Name of the VSCode multi-root workspace file that is generated in the workspace root and opened on launch so that the imported projects are loaded as
+ * project roots (e.g. by the Java language server).
+ */
+ private static final String WORKSPACE_FILE = "ide.code-workspace";
+
+ /** Map of build tool classes to their corresponding VSCode workspace template. */
+ private static final Map, String> BUILD_TOOL_TO_TEMPLATE = Map.of(Mvn.class, WORKSPACE_FILE, Gradle.class,
+ WORKSPACE_FILE);
+
+ /** The {@code folders} key of a VSCode multi-root {@code .code-workspace} file. */
+ private static final String FOLDERS_KEY = "folders";
+
+ /** The {@code path} key of a single entry in the {@link #FOLDERS_KEY folders} array. */
+ private static final String FOLDER_PATH_KEY = "path";
+
+ /** JSON mapper for reading/writing the (untyped) {@code .code-workspace} file. */
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
/**
* The constructor.
*
@@ -155,8 +184,154 @@ protected void configureToolArgs(ProcessContext pc, ProcessMode processMode, Lis
}
Path vsCodeExtensionFolder = this.context.getIdeHome().resolve("plugins/vscode");
pc.addArg("--extensions-dir=" + vsCodeExtensionFolder);
- pc.addArg(this.context.getWorkspacePath());
+ // Open the multi-root workspace file (if present) so the imported projects are loaded as project roots; fall back to the workspace folder.
+ pc.addArg(getWorkspaceTarget());
super.configureToolArgs(pc, processMode, args);
}
+ /**
+ * @return the {@link Path} to open when launching VSCode: the multi-root {@code .code-workspace} file in the workspace root if it exists (created via
+ * {@link #importRepository(Path)}), otherwise the workspace folder itself.
+ */
+ protected Path getWorkspaceTarget() {
+
+ Path workspaceFolder = this.context.getWorkspacePath();
+ Path workspaceFile = workspaceFolder.resolve(WORKSPACE_FILE);
+ if (Files.exists(workspaceFile)) {
+ return workspaceFile;
+ }
+ return workspaceFolder;
+ }
+
+ @Override
+ public Map, String> getBuildTool2TemplateMap() {
+
+ return BUILD_TOOL_TO_TEMPLATE;
+ }
+
+ /**
+ * The VSCode multi-root workspace template lives directly in the workspace root (no configuration sub-folder such as {@code .vscode}), so the template
+ * folder is empty and the template file is placed in the root of both the settings repository and the workspace.
+ *
+ * @return an empty {@link String}, see {@code IdeToolCommandlet#getTemplateFolder()}.
+ */
+ @Override
+ public String getTemplateFolder() {
+
+ return "";
+ }
+
+ /**
+ * Merges the repository workspace template into the workspace's multi-root {@link #WORKSPACE_FILE} file (in the workspace root). The folders contributed
+ * by the imported repository (with {@code PROJECT_PATH} resolved to the project's relative path) are appended to the existing
+ * {@code folders} array, so that multiple imported repositories become the multiple roots of a single VSCode workspace. Roots that no longer exist on
+ * disk are dropped (auto-cleanup) and duplicates (by {@code path}) are not added again.
+ *
+ * @param templateFile the resolved {@link Path} to the workspace template in the settings repository.
+ * @param workspaceFile the {@link Path} to the workspace {@code .code-workspace} file to merge into.
+ * @param environmentVariables the {@link EnvironmentVariables} to resolve variables (e.g. {@code PROJECT_PATH}) in the template.
+ */
+ @Override
+ public void doMergeTemplate(Path templateFile, Path workspaceFile, EnvironmentVariables environmentVariables) {
+
+ List newFolders = readResolvedFolders(templateFile, environmentVariables);
+ ObjectNode workspace = readWorkspaceFile(workspaceFile);
+ ArrayNode folders = workspace.get(FOLDERS_KEY) instanceof ArrayNode existingFolders ? existingFolders : workspace.putArray(FOLDERS_KEY);
+ Path workspaceRoot = workspaceFile.getParent();
+ // auto-cleanup: drop roots that no longer exist on disk
+ for (int i = folders.size() - 1; i >= 0; i--) {
+ if (isStaleFolder(folders.get(i), workspaceRoot)) {
+ folders.remove(i);
+ }
+ }
+ // add the imported project root (if not already present)
+ for (String folderPath : newFolders) {
+ if (!containsFolder(folders, folderPath)) {
+ folders.addObject().put(FOLDER_PATH_KEY, folderPath);
+ }
+ }
+ writeFileContent(workspace, workspaceFile);
+ LOG.debug("Merged VSCode workspace file into {} ({} folder(s))", workspaceFile, folders.size());
+ }
+
+ /**
+ * Reads the {@code folders} array of the given workspace template and resolves the {@code path} of each entry (e.g. {@code $[PROJECT_PATH]}) to a
+ * concrete project path.
+ */
+ private List readResolvedFolders(Path templateFile, EnvironmentVariables environmentVariables) {
+
+ try {
+ JsonNode template = MAPPER.readTree(this.context.getFileAccess().readFileContent(templateFile));
+ List result = new ArrayList<>();
+ if (template.get(FOLDERS_KEY) instanceof JsonNode folders && folders.isArray()) {
+ for (JsonNode folder : folders) {
+ if (folder.get(FOLDER_PATH_KEY) instanceof TextNode pathNode) {
+ result.add(environmentVariables.resolve(pathNode.asText(), templateFile, false));
+ }
+ }
+ }
+ return result;
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to read VSCode workspace template from " + templateFile, e);
+ }
+ }
+
+ /**
+ * @param workspaceFile the {@link Path} to the {@code .code-workspace} file to read.
+ * @return the existing workspace JSON as an {@link ObjectNode}, or a new empty object if the file does not yet exist.
+ */
+ private ObjectNode readWorkspaceFile(Path workspaceFile) {
+
+ if (!Files.exists(workspaceFile)) {
+ return MAPPER.createObjectNode();
+ }
+ try {
+ JsonNode node = MAPPER.readTree(this.context.getFileAccess().readFileContent(workspaceFile));
+ return node instanceof ObjectNode objectNode ? objectNode : MAPPER.createObjectNode();
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to read VSCode workspace file " + workspaceFile, e);
+ }
+ }
+
+ /**
+ * @return {@code true} if the given {@code folders} array already contains a folder with the given {@code path}.
+ */
+ private boolean containsFolder(JsonNode folders, String folderPath) {
+
+ for (JsonNode folder : folders) {
+ if (folderPath.equals(folder.get(FOLDER_PATH_KEY).asText())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * @param folder the folder entry of the {@code folders} array.
+ * @param workspaceRoot the {@link Path} of the workspace root the {@code .code-workspace} file belongs to.
+ * @return {@code true} if the folder's {@code path} is missing or no longer exists on disk (relative to the workspace root), so that it should be
+ * dropped (auto-cleanup).
+ */
+ private boolean isStaleFolder(JsonNode folder, Path workspaceRoot) {
+
+ JsonNode pathNode = folder.get(FOLDER_PATH_KEY);
+ if (!(pathNode instanceof TextNode textNode) || textNode.asText().isBlank()) {
+ return false; // not a path entry -> keep it
+ }
+ return !Files.exists(workspaceRoot.resolve(textNode.asText()));
+ }
+
+ /**
+ * Writes the given workspace JSON to the file, pretty-printed with two spaces (as expected by the VSCode {@code .code-workspace} format).
+ */
+ private void writeFileContent(JsonNode workspace, Path workspaceFile) {
+
+ try {
+ String content = MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(workspace);
+ this.context.getFileAccess().writeFileContent(content, workspaceFile);
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to write VSCode workspace file " + workspaceFile, e);
+ }
+ }
+
}
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 e0657cfd81..08884599d8 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
@@ -1,8 +1,11 @@
package com.devonfw.tools.ide.tool.vscode;
+import java.io.IOException;
+import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
+import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -364,6 +367,188 @@ void testCsvParsingOfExcludedEditions() {
assertThat(excludedEditions).contains("vscode", "vscodium");
}
+ /**
+ * Tests that {@link Vscode#importRepository(Path)} creates a multi-root {@code .code-workspace} file for a Maven project so the project is opened as a
+ * project root on launch.
+ */
+ @Test
+ void testVscodeMvnRepositoryImport() {
+
+ // arrange
+ IdeTestContext context = newContext(PROJECT_VSCODE);
+ Vscode vscodeCommandlet = new Vscode(context);
+ Path repositoryPath = context.getWorkspacePath().resolve("test_mvn");
+
+ // act
+ vscodeCommandlet.importRepository(repositoryPath);
+
+ // assert
+ Path workspaceFile = context.getWorkspacePath().resolve("ide.code-workspace");
+ assertThat(workspaceFile).exists().content().contains("folders").contains("test_mvn");
+ }
+
+ /**
+ * Tests that {@link Vscode#importRepository(Path)} accumulates multiple imported projects as the multiple roots of a single {@code .code-workspace}
+ * file and does not create duplicates when a project is imported twice.
+ */
+ @Test
+ void testVscodeRepositoryImportAccumulatesMultipleProjects() {
+
+ // arrange
+ IdeTestContext context = newContext(PROJECT_VSCODE);
+ Vscode vscodeCommandlet = new Vscode(context);
+ Path workspaceFile = context.getWorkspacePath().resolve("ide.code-workspace");
+
+ // act: import two distinct projects into the same workspace
+ vscodeCommandlet.importRepository(context.getWorkspacePath().resolve("test_mvn"));
+ vscodeCommandlet.importRepository(context.getWorkspacePath().resolve("test_mvn2"));
+
+ // assert: both projects are roots of the single workspace
+ assertThat(context.getFileAccess().readFileContent(workspaceFile)).contains("test_mvn", "test_mvn2");
+ assertThat(countOccurrences(context.getFileAccess().readFileContent(workspaceFile), "\"path\"")).isEqualTo(2);
+
+ // act: re-import an already imported project
+ vscodeCommandlet.importRepository(context.getWorkspacePath().resolve("test_mvn"));
+
+ // assert: no duplicate is added
+ assertThat(countOccurrences(context.getFileAccess().readFileContent(workspaceFile), "\"path\"")).isEqualTo(2);
+ }
+
+ /**
+ * Tests that {@link Vscode#importRepository(Path)} drops a root from the {@code .code-workspace} file when the corresponding project folder no longer
+ * exists on disk (auto-cleanup).
+ */
+ @Test
+ void testVscodeRepositoryImportRemovesDeletedProject() {
+
+ // arrange
+ IdeTestContext context = newContext(PROJECT_VSCODE);
+ Vscode vscodeCommandlet = new Vscode(context);
+ Path workspacePath = context.getWorkspacePath();
+ Path workspaceFile = workspacePath.resolve("ide.code-workspace");
+ Path deletedProject = workspacePath.resolve("test_mvn");
+ Path keptProject = workspacePath.resolve("test_mvn2");
+
+ // act: import two projects, then delete one of them from disk and import it again (or import the other)
+ vscodeCommandlet.importRepository(deletedProject);
+ vscodeCommandlet.importRepository(keptProject);
+ deleteRecursively(deletedProject);
+ vscodeCommandlet.importRepository(keptProject);
+
+ // assert: the deleted project is pruned, the kept project remains
+ String content = context.getFileAccess().readFileContent(workspaceFile);
+ assertThat(content).doesNotContain("test_mvn\"").contains("test_mvn2");
+ assertThat(countOccurrences(content, "\"path\"")).isEqualTo(1);
+ }
+
+ /**
+ * Recursively deletes the given file or directory.
+ */
+ private static void deleteRecursively(Path path) {
+
+ if (!Files.exists(path)) {
+ return;
+ }
+ try (var paths = Files.walk(path)) {
+ paths.sorted(Comparator.reverseOrder()).forEach(p -> {
+ try {
+ Files.delete(p);
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to delete " + p, e);
+ }
+ });
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to delete " + path, e);
+ }
+ }
+
+ /**
+ * Counts the occurrences of the given needle in the given haystack.
+ */
+ private static int countOccurrences(String haystack, String needle) {
+
+ int count = 0;
+ int index = 0;
+ while ((index = haystack.indexOf(needle, index)) != -1) {
+ count++;
+ index += needle.length();
+ }
+ return count;
+ }
+
+ /**
+ * Tests that {@link Vscode#importRepository(Path)} creates a multi-root {@code .code-workspace} file for a Gradle project in a non-main workspace, with
+ * the project path relative to that workspace.
+ */
+ @Test
+ void testVscodeGradleRepositoryImport() {
+
+ // arrange
+ IdeTestContext context = newContext(PROJECT_VSCODE);
+ Vscode vscodeCommandlet = new Vscode(context);
+ Path repositoryPath = context.getWorkspacePath("test").resolve("subfolder/test_gradle");
+
+ // act
+ vscodeCommandlet.importRepository(repositoryPath);
+
+ // assert
+ Path workspaceFile = context.getWorkspacePath("test").resolve("ide.code-workspace");
+ assertThat(workspaceFile).exists().content().contains("folders").contains("subfolder/test_gradle");
+ }
+
+ /**
+ * Tests that {@link Vscode#importRepository(Path)} logs a warning and creates no {@code .code-workspace} file when the repository contains no supported
+ * build descriptor.
+ */
+ @Test
+ void testVscodeRepositoryImportWithoutSupportedBuildDescriptor() {
+
+ // arrange
+ IdeTestContext context = newContext(PROJECT_VSCODE);
+ Vscode vscodeCommandlet = new Vscode(context);
+ Path repositoryPath = context.getWorkspacePath().resolve("empty_project");
+ context.getFileAccess().mkdirs(repositoryPath);
+
+ // act
+ vscodeCommandlet.importRepository(repositoryPath);
+
+ // assert
+ assertThat(context).logAtWarning().hasMessageContaining("No supported build descriptor was found for project import in");
+ assertThat(context.getWorkspacePath().resolve("ide.code-workspace")).doesNotExist();
+ }
+
+ /**
+ * Tests that {@link Vscode#getWorkspaceTarget()} falls back to the workspace folder when no {@code .code-workspace} file has been created yet.
+ */
+ @Test
+ void testVscodeWorkspaceTargetFallsBackToFolder() {
+
+ // arrange
+ IdeTestContext context = newContext(PROJECT_VSCODE);
+ Vscode vscodeCommandlet = new Vscode(context);
+
+ // act & assert
+ assertThat(vscodeCommandlet.getWorkspaceTarget()).isEqualTo(context.getWorkspacePath());
+ }
+
+ /**
+ * Tests that {@link Vscode#getWorkspaceTarget()} returns the {@code .code-workspace} file so that VSCode opens it (and thus loads the imported projects
+ * as project roots) once the file has been created by a repository import.
+ */
+ @Test
+ void testVscodeWorkspaceTargetUsesWorkspaceFile() {
+
+ // arrange
+ IdeTestContext context = newContext(PROJECT_VSCODE);
+ Vscode vscodeCommandlet = new Vscode(context);
+ Path workspaceFile = context.getWorkspacePath().resolve("ide.code-workspace");
+ context.getFileAccess().writeFileContent("{\"folders\": []}", workspaceFile);
+
+ // act & assert
+ assertThat(vscodeCommandlet.getWorkspaceTarget()).isEqualTo(workspaceFile);
+ }
+
+
/**
* Test double for {@link Vscode} that captures CLI arguments passed to {@link #runTool(ProcessContext, ProcessMode, List)} so tests can assert command
* construction without spawning an external process.
diff --git a/cli/src/test/resources/ide-projects/vscode/project/settings/vscode/workspace/repository/ide.code-workspace b/cli/src/test/resources/ide-projects/vscode/project/settings/vscode/workspace/repository/ide.code-workspace
new file mode 100644
index 0000000000..124d2e1d6a
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/vscode/project/settings/vscode/workspace/repository/ide.code-workspace
@@ -0,0 +1,5 @@
+{
+ "folders": [
+ { "path": "$[PROJECT_PATH]" }
+ ]
+}
diff --git a/cli/src/test/resources/ide-projects/vscode/project/workspaces/main/test_mvn/pom.xml b/cli/src/test/resources/ide-projects/vscode/project/workspaces/main/test_mvn/pom.xml
new file mode 100644
index 0000000000..dd1d337c45
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/vscode/project/workspaces/main/test_mvn/pom.xml
@@ -0,0 +1,6 @@
+
+
+ 4.0.0
+ test-mvn
+
\ No newline at end of file
diff --git a/cli/src/test/resources/ide-projects/vscode/project/workspaces/main/test_mvn2/pom.xml b/cli/src/test/resources/ide-projects/vscode/project/workspaces/main/test_mvn2/pom.xml
new file mode 100644
index 0000000000..1d80998aff
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/vscode/project/workspaces/main/test_mvn2/pom.xml
@@ -0,0 +1,6 @@
+
+
+ 4.0.0
+ test-mvn2
+
diff --git a/cli/src/test/resources/ide-projects/vscode/project/workspaces/test/subfolder/test_gradle/build.gradle b/cli/src/test/resources/ide-projects/vscode/project/workspaces/test/subfolder/test_gradle/build.gradle
new file mode 100644
index 0000000000..c9d13d7539
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/vscode/project/workspaces/test/subfolder/test_gradle/build.gradle
@@ -0,0 +1,3 @@
+plugins {
+ id 'java'
+}
\ No newline at end of file