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