diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/chat/service/CustomizationFileServiceTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/chat/service/CustomizationFileServiceTests.java new file mode 100644 index 000000000..04ad5a980 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/chat/service/CustomizationFileServiceTests.java @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.file.Path; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CustomizationFileInfo; + +class CustomizationFileServiceTests { + + @TempDir + Path tempDir; + + @Test + void testRefreshAllAsync_ignoresResultOfSupersededRefresh() { + CopilotLanguageServerConnection lsConnection = mock(CopilotLanguageServerConnection.class); + CompletableFuture olderRefresh = new CompletableFuture<>(); + CompletableFuture newerRefresh = new CompletableFuture<>(); + when(lsConnection.listCustomInstructions(any())).thenReturn(olderRefresh, newerRefresh); + when(lsConnection.listCustomSkills(any())).thenReturn(emptyResult()); + when(lsConnection.listCustomPrompts(any())).thenReturn(emptyResult()); + when(lsConnection.listCustomAgents(any())).thenReturn(emptyResult()); + Path staleFile = tempDir.resolve("stale.instructions.md"); + Path currentFile = tempDir.resolve("current.instructions.md"); + + CustomizationFileService service = new CustomizationFileService(lsConnection); + try { + service.refreshAllAsync(); + verify(lsConnection, timeout(5000).times(1)).listCustomInstructions(any()); + service.refreshAllAsync(); + verify(lsConnection, timeout(5000).times(2)).listCustomInstructions(any()); + + newerRefresh.complete(new CustomizationFileInfo[] { toFileInfo(currentFile) }); + olderRefresh.complete(new CustomizationFileInfo[] { toFileInfo(staleFile) }); + + assertEquals(Set.of(currentFile.toAbsolutePath().normalize()), service.getCustomizationFiles()); + } finally { + service.dispose(); + } + } + + private static CompletableFuture emptyResult() { + return CompletableFuture.completedFuture(new CustomizationFileInfo[0]); + } + + private static CustomizationFileInfo toFileInfo(Path file) { + return new CustomizationFileInfo(null, file.getFileName().toString(), file.toUri().toString(), "local"); + } +} diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplateTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplateTests.java new file mode 100644 index 000000000..36e50ae79 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplateTests.java @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import com.google.gson.Gson; +import org.junit.jupiter.api.Test; + +class ConversationTemplateTests { + + private final Gson gson = new Gson(); + + @Test + void testCommandName_promptFile_returnsName() { + String json = """ + { + "id": "file:///c%3A/repo/.github/prompts/review.prompt.md", + "name": "review", + "description": "", + "scopes": ["chat-panel", "agent-panel"], + "source": "prompt" + } + """; + + ConversationTemplate template = gson.fromJson(json, ConversationTemplate.class); + + assertEquals("review", template.commandName()); + } + + @Test + void testCommandName_promptFileWithoutName_returnsId() { + ConversationTemplate template = new ConversationTemplate("file:///repo/review.prompt.md", null, null, null, + List.of(), TemplateSource.PROMPT); + + assertEquals("file:///repo/review.prompt.md", template.commandName()); + } + + @Test + void testCommandName_skill_returnsId() { + ConversationTemplate template = new ConversationTemplate("skill:marker-skill", "marker-skill", null, null, + List.of(), TemplateSource.SKILL); + + assertEquals("skill:marker-skill", template.commandName()); + } + + @Test + void testCommandName_builtin_returnsId() { + ConversationTemplate template = new ConversationTemplate("explain", null, null, null, List.of(), + TemplateSource.BUILTIN); + + assertEquals("explain", template.commandName()); + } +} diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/utils/WorkspaceUtilsTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/utils/WorkspaceUtilsTests.java index 16982d571..662da201a 100644 --- a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/utils/WorkspaceUtilsTests.java +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/utils/WorkspaceUtilsTests.java @@ -3,26 +3,41 @@ package com.microsoft.copilot.eclipse.core.utils; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; +import java.util.Optional; import org.eclipse.core.resources.IProject; import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.preferences.IEclipsePreferences; +import org.eclipse.core.runtime.preferences.InstanceScope; +import org.eclipse.lsp4j.WorkspaceFolder; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import com.microsoft.copilot.eclipse.core.Constants; + class WorkspaceUtilsTests { + private static final String UI_PREFERENCE_NODE = "com.microsoft.copilot.eclipse.ui"; + @TempDir Path tempDir; + @AfterEach + void tearDown() { + uiPreferences().remove(Constants.CUSTOM_INSTRUCTIONS_PARENT_REPO_ENABLED); + } + @Test void testIsGitRepository_withGitFolder() throws IOException { // Create a temporary .git directory @@ -97,4 +112,139 @@ void testIsGitRepository_withNullLocation() { assertFalse(WorkspaceUtils.isGitRepository(project)); } + @Test + void testFindRepositoryRoot_withGitFolderInFolderItself() throws IOException { + Path repo = Files.createDirectories(tempDir.resolve("repo/.git")).getParent(); + + assertEquals(Optional.of(repo), WorkspaceUtils.findRepositoryRoot(repo)); + } + + @Test + void testFindRepositoryRoot_withGitFolderInAncestor() throws IOException { + Path repo = Files.createDirectories(tempDir.resolve("repo/.git")).getParent(); + Path module = Files.createDirectories(repo.resolve("modules/app")); + + assertEquals(Optional.of(repo), WorkspaceUtils.findRepositoryRoot(module)); + } + + @Test + void testFindRepositoryRoot_withGitFile() throws IOException { + // git worktrees and submodules use a .git file instead of a folder + Path worktree = Files.createDirectories(tempDir.resolve("worktree")); + Files.createFile(worktree.resolve(".git")); + Path module = Files.createDirectories(worktree.resolve("app")); + + assertEquals(Optional.of(worktree), WorkspaceUtils.findRepositoryRoot(module)); + } + + @Test + void testFindRepositoryRoot_outsideRepository() throws IOException { + Path folder = Files.createDirectories(tempDir.resolve("no-repo/app")); + + // only check below the temp dir, the temp dir itself might be located in a git repository + assertTrue(WorkspaceUtils.findRepositoryRoot(folder).filter(root -> root.startsWith(tempDir)).isEmpty()); + } + + @Test + void testWithParentRepositoryFolders_addsAncestorsUpToRepositoryRoot() throws IOException { + Path repo = Files.createDirectories(tempDir.resolve("repo/.git")).getParent(); + Path modules = Files.createDirectories(repo.resolve("modules")); + Path app = Files.createDirectories(modules.resolve("app")); + + List result = WorkspaceUtils.withParentRepositoryFolders(List.of(toWorkspaceFolder(app))); + + assertEquals(List.of(app, modules, repo), toPaths(result)); + assertEquals(List.of("app", "modules", "repo"), result.stream().map(WorkspaceFolder::getName).toList()); + } + + @Test + void testWithParentRepositoryFolders_keepsRepositoryRootUnchanged() throws IOException { + Path repo = Files.createDirectories(tempDir.resolve("repo/.git")).getParent(); + List folders = List.of(toWorkspaceFolder(repo)); + + assertEquals(folders, WorkspaceUtils.withParentRepositoryFolders(folders)); + } + + @Test + void testWithParentRepositoryFolders_avoidsDuplicates() throws IOException { + Path repo = Files.createDirectories(tempDir.resolve("repo/.git")).getParent(); + Path moduleA = Files.createDirectories(repo.resolve("a")); + Path moduleB = Files.createDirectories(repo.resolve("b")); + + List result = WorkspaceUtils.withParentRepositoryFolders( + List.of(toWorkspaceFolder(moduleA), toWorkspaceFolder(moduleB), toWorkspaceFolder(repo))); + + assertEquals(List.of(moduleA, moduleB, repo), toPaths(result)); + } + + @Test + void testWithParentRepositoryFolders_stopsAtSubmoduleRoot() throws IOException { + Path repo = Files.createDirectories(tempDir.resolve("repo/.git")).getParent(); + Path submodule = Files.createDirectories(repo.resolve("submodule")); + Files.createFile(submodule.resolve(".git")); + Path app = Files.createDirectories(submodule.resolve("app")); + + List result = WorkspaceUtils.withParentRepositoryFolders(List.of(toWorkspaceFolder(app))); + + assertEquals(List.of(app, submodule), toPaths(result)); + } + + @Test + void testWithParentRepositoryFolders_keepsNonFileFolders() { + List folders = List.of(new WorkspaceFolder("jdt://contents/rt.jar", "rt.jar")); + + assertEquals(folders, WorkspaceUtils.withParentRepositoryFolders(folders)); + } + + @Test + void testListParentRepositoryFolders_returnsOnlyAdditionalAncestors() throws IOException { + Path repo = Files.createDirectories(tempDir.resolve("repo/.git")).getParent(); + Path modules = Files.createDirectories(repo.resolve("modules")); + Path app = Files.createDirectories(modules.resolve("app")); + + List result = WorkspaceUtils.listParentRepositoryFolders( + List.of(toWorkspaceFolder(app), toWorkspaceFolder(modules))); + + assertEquals(List.of(repo), toPaths(result)); + } + + @Test + void testIsParentRepositoryEnabled_trueByDefault() { + assertTrue(WorkspaceUtils.isParentRepositoryEnabled()); + } + + @Test + void testWithParentRepositoryFoldersIfEnabled_addsAncestorsWhenEnabled() throws IOException { + Path repo = Files.createDirectories(tempDir.resolve("repo/.git")).getParent(); + Path app = Files.createDirectories(repo.resolve("app")); + uiPreferences().putBoolean(Constants.CUSTOM_INSTRUCTIONS_PARENT_REPO_ENABLED, true); + + List result = WorkspaceUtils.withParentRepositoryFoldersIfEnabled( + List.of(toWorkspaceFolder(app))); + + assertEquals(List.of(app, repo), toPaths(result)); + } + + @Test + void testWithParentRepositoryFoldersIfEnabled_keepsFoldersWhenDisabled() throws IOException { + Path repo = Files.createDirectories(tempDir.resolve("repo/.git")).getParent(); + Path app = Files.createDirectories(repo.resolve("app")); + uiPreferences().putBoolean(Constants.CUSTOM_INSTRUCTIONS_PARENT_REPO_ENABLED, false); + List folders = List.of(toWorkspaceFolder(app)); + + assertEquals(folders, WorkspaceUtils.withParentRepositoryFoldersIfEnabled(folders)); + } + + private static IEclipsePreferences uiPreferences() { + return InstanceScope.INSTANCE.getNode(UI_PREFERENCE_NODE); + } + + private static WorkspaceFolder toWorkspaceFolder(Path path) { + return new WorkspaceFolder(path.toUri().toASCIIString(), path.getFileName().toString()); + } + + private static List toPaths(List folders) { + return folders.stream().map(folder -> FileUtils.getLocalFilePath(folder.getUri())).toList(); + } + } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java index 73817770d..99e8680ed 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java @@ -40,6 +40,7 @@ private Constants() { public static final String CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE = "customInstructionsChatLoadScope"; public static final String CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE_ALL = "allProjects"; public static final String CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE_REFERENCED = "referencedProjects"; + public static final String CUSTOM_INSTRUCTIONS_PARENT_REPO_ENABLED = "customInstructionsParentRepoEnabled"; public static final String GITHUB_COPILOT_URL = "http://github.com"; @Deprecated public static final String QUICK_START_VERSION = "quickStartVersion"; diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/CustomizationFileService.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/CustomizationFileService.java index c94c14f7a..7fa4bf492 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/CustomizationFileService.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/CustomizationFileService.java @@ -5,10 +5,13 @@ import java.nio.file.Path; import java.util.ArrayList; +import java.util.EnumMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicLong; import org.eclipse.e4.core.contexts.EclipseContextFactory; import org.eclipse.e4.core.services.events.IEventBroker; @@ -40,6 +43,8 @@ public class CustomizationFileService implements ICustomizationFileService { private volatile Set customizationFiles = Set.of(); + private final Map refreshGenerations = new EnumMap<>(CustomizationType.class); + /** * Creates the service and subscribes to customization-file change events. * @@ -47,11 +52,14 @@ public class CustomizationFileService implements ICustomizationFileService { */ public CustomizationFileService(CopilotLanguageServerConnection lsConnection) { this.lsConnection = lsConnection; + for (CustomizationType type : CustomizationType.values()) { + refreshGenerations.put(type, new AtomicLong()); + } this.eventBroker = EclipseContextFactory .getServiceContext(FrameworkUtil.getBundle(getClass()).getBundleContext()).get(IEventBroker.class); this.customizationFilesChangedHandler = event -> { if (event.getProperty(IEventBroker.DATA) instanceof CustomizationType type) { - CompletableFuture.runAsync(() -> refreshType(type, WorkspaceUtils.listWorkspaceFolders())); + CompletableFuture.runAsync(() -> refreshType(type, WorkspaceUtils.listCustomizationFolders())); } }; if (eventBroker != null) { @@ -80,7 +88,7 @@ public Set getSkillFolders() { @Override public void refreshAllAsync() { CompletableFuture.runAsync(() -> { - List workspaceFolders = WorkspaceUtils.listWorkspaceFolders(); + List workspaceFolders = WorkspaceUtils.listCustomizationFolders(); for (CustomizationType type : CustomizationType.values()) { refreshType(type, workspaceFolders); } @@ -88,8 +96,12 @@ public void refreshAllAsync() { } private void refreshType(CustomizationType type, List workspaceFolders) { + long generation = refreshGenerations.get(type).incrementAndGet(); switch (type) { case SKILL -> toPaths(lsConnection.listCustomSkills(workspaceFolders)).thenAccept(paths -> { + if (isSuperseded(type, generation)) { + return; + } Set folders = new HashSet<>(); for (Path skillFile : paths) { Path parent = skillFile.getParent(); @@ -100,14 +112,23 @@ private void refreshType(CustomizationType type, List workspace this.skillFolders = Set.copyOf(folders); }); case PROMPT -> toPaths(lsConnection.listCustomPrompts(workspaceFolders)).thenAccept(paths -> { + if (isSuperseded(type, generation)) { + return; + } this.promptFiles = Set.copyOf(paths); rebuildCustomizationFiles(); }); case INSTRUCTION -> toPaths(lsConnection.listCustomInstructions(workspaceFolders)).thenAccept(paths -> { + if (isSuperseded(type, generation)) { + return; + } this.instructionFiles = Set.copyOf(paths); rebuildCustomizationFiles(); }); case AGENT -> toPaths(lsConnection.listCustomAgents(workspaceFolders)).thenAccept(paths -> { + if (isSuperseded(type, generation)) { + return; + } this.agentFiles = Set.copyOf(paths); rebuildCustomizationFiles(); }); @@ -117,6 +138,13 @@ private void refreshType(CustomizationType type, List workspace } } + /** + * Refreshes of the same type may complete out of order; only the most recently started one may update the state. + */ + private boolean isSuperseded(CustomizationType type, long generation) { + return refreshGenerations.get(type).get() != generation; + } + private synchronized void rebuildCustomizationFiles() { Set all = new HashSet<>(promptFiles); all.addAll(instructionFiles); diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/FileBasedCustomModeService.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/FileBasedCustomModeService.java index f0543098c..f8582c14c 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/FileBasedCustomModeService.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/FileBasedCustomModeService.java @@ -47,7 +47,7 @@ private Path getAgentsDirectory(WorkspaceFolder workspaceFolder) { @Override public CompletableFuture> loadCustomModes() { // Get workspace folders for LSP call - List workspaceFolders = WorkspaceUtils.listWorkspaceFolders(); + List workspaceFolders = WorkspaceUtils.listCustomizationFolders(); ConversationModesParams params = new ConversationModesParams(workspaceFolders); // Call LSP to get modes from all projects diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplate.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplate.java index 233035718..9aeaa0649 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplate.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplate.java @@ -5,13 +5,26 @@ import java.util.List; +import org.apache.commons.lang3.StringUtils; + /** * Represents a conversation template returned by the language server. */ public record ConversationTemplate( String id, + String name, String description, String shortDescription, List scopes, TemplateSource source) { + + /** + * Returns the name to invoke this template as slash command. Prompt files are identified by their file URI, but the + * language server resolves them by their name. + * + * @return the slash command name without the leading slash + */ + public String commandName() { + return source == TemplateSource.PROMPT && StringUtils.isNotBlank(name) ? name : id; + } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/WorkspaceUtils.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/WorkspaceUtils.java index 28fce9ebf..e88b48d55 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/WorkspaceUtils.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/WorkspaceUtils.java @@ -5,19 +5,30 @@ import java.io.File; import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Optional; +import java.util.Set; import org.eclipse.core.resources.IProject; import org.eclipse.core.resources.ResourcesPlugin; import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.Platform; import org.eclipse.lsp4j.WorkspaceFolder; +import com.microsoft.copilot.eclipse.core.Constants; + /** * Utils for workspace-related operations. */ public class WorkspaceUtils { + private static final String GIT_FOLDER = ".git"; + private static final String UI_PREFERENCE_NODE = "com.microsoft.copilot.eclipse.ui"; + /** * List all top-level workspace projects in the current workspace. * @@ -100,6 +111,38 @@ public static List listWorkspaceFolders() { return folders; } + /** + * List the workspace folders in which customization files such as instructions, prompts, skills and agents are + * discovered, i.e. all top level projects, extended by their parent repository folders if enabled. + * + * @return list of workspace folders for discovering customization files + */ + public static List listCustomizationFolders() { + return withParentRepositoryFoldersIfEnabled(listWorkspaceFolders()); + } + + /** + * Check if the preference for discovering customization files in the parent git repositories of the projects is + * enabled. + * + * @return true if the parent repository folders should be included, false otherwise + */ + public static boolean isParentRepositoryEnabled() { + return Platform.getPreferencesService().getBoolean(UI_PREFERENCE_NODE, + Constants.CUSTOM_INSTRUCTIONS_PARENT_REPO_ENABLED, true, null); + } + + /** + * Extend the given workspace folders by {@link #withParentRepositoryFolders(List)} if + * {@link #isParentRepositoryEnabled()}. + * + * @param folders the workspace folders to extend + * @return the extended workspace folders, or the given folders if the preference is disabled + */ + public static List withParentRepositoryFoldersIfEnabled(List folders) { + return isParentRepositoryEnabled() ? withParentRepositoryFolders(folders) : folders; + } + /** * Check if a project is a git repository by looking for the .git folder. * @@ -118,8 +161,77 @@ public static boolean isGitRepository(IProject project) { return false; } - File gitFolder = new File(location.toFile(), ".git"); + File gitFolder = new File(location.toFile(), GIT_FOLDER); return gitFolder.exists() && gitFolder.isDirectory(); } + /** + * Find the root of the git repository containing the given folder. Unlike {@link #isGitRepository(IProject)}, a .git + * file is accepted as well, as it is used by git worktrees and submodules. + * + * @param folder the folder to start the search from + * @return the repository root, or an empty optional if the folder is not located in a git repository + */ + public static Optional findRepositoryRoot(Path folder) { + for (Path current = folder; current != null; current = current.getParent()) { + if (Files.exists(current.resolve(GIT_FOLDER))) { + return Optional.of(current); + } + } + return Optional.empty(); + } + + /** + * Extend the given workspace folders with the folders of their enclosing git repositories. For every workspace folder + * that is located inside a git repository but is not its root, all ancestor folders up to and including the + * repository root are appended. This way customization files such as .github/copilot-instructions.md are discovered + * even if only a nested project of the repository is imported into the Eclipse workspace. + * + * @param folders the workspace folders to extend + * @return the given folders, followed by the additional ancestor folders without duplicates + */ + public static List withParentRepositoryFolders(List folders) { + List result = new ArrayList<>(folders); + result.addAll(listParentRepositoryFolders(folders)); + return result; + } + + /** + * List the ancestor folders up to and including the git repository root of the given workspace folders, as appended + * by {@link #withParentRepositoryFolders(List)}. + * + * @param folders the workspace folders + * @return the ancestor folders that are not contained in the given folders, without duplicates + */ + public static List listParentRepositoryFolders(List folders) { + List result = new ArrayList<>(); + Set knownPaths = new HashSet<>(); + List folderPaths = new ArrayList<>(); + for (WorkspaceFolder folder : folders) { + Path path = FileUtils.getLocalFilePath(folder.getUri()); + if (path != null && knownPaths.add(path)) { + folderPaths.add(path); + } + } + + for (Path path : folderPaths) { + Optional repositoryRoot = findRepositoryRoot(path); + if (repositoryRoot.isEmpty()) { + continue; + } + for (Path ancestor = path.getParent(); ancestor != null + && ancestor.startsWith(repositoryRoot.get()); ancestor = ancestor.getParent()) { + if (knownPaths.add(ancestor)) { + result.add(toWorkspaceFolder(ancestor)); + } + } + } + return result; + } + + private static WorkspaceFolder toWorkspaceFolder(Path path) { + Path fileName = path.getFileName(); + return new WorkspaceFolder(path.toUri().toASCIIString(), fileName != null ? fileName.toString() : path.toString()); + } + } diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessorTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessorTest.java index 7c0e68802..18020204f 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessorTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessorTest.java @@ -4,14 +4,32 @@ package com.microsoft.copilot.eclipse.ui.chat; import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import java.lang.reflect.Constructor; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.eclipse.jface.text.Document; import org.eclipse.jface.text.TextViewer; +import org.eclipse.jface.text.contentassist.ICompletionProposal; +import org.eclipse.jface.text.contentassist.ICompletionProposalExtension6; import org.eclipse.jface.text.contentassist.IContentAssistProcessor; +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; import org.junit.jupiter.api.Test; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatMode; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationTemplate; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotScope; +import com.microsoft.copilot.eclipse.core.lsp.protocol.TemplateSource; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatCompletionService; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.chat.services.UserPreferenceService; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; class ChatAssistProcessorTest { @@ -26,4 +44,61 @@ void testContentAssistAutoActivationUsesSlashOnly() throws ReflectiveOperationEx assertArrayEquals(new char[] { '/' }, processor.getCompletionProposalAutoActivationCharacters()); assertArrayEquals(new char[] { '/' }, processor.getContextInformationAutoActivationCharacters()); } + + @Test + void testComputeCompletionProposals_promptFileShowsAndInsertsName() { + ConversationTemplate promptTemplate = new ConversationTemplate( + "file:///c%3A/repo/.github/prompts/review.prompt.md", "review", "", "", List.of(CopilotScope.AGENT_PANEL), + TemplateSource.PROMPT); + ChatServiceManager chatServiceManager = mockChatServiceManager(promptTemplate); + AtomicReference displayString = new AtomicReference<>(); + AtomicReference styledDisplayString = new AtomicReference<>(); + AtomicReference insertedText = new AtomicReference<>(); + + SwtUtils.invokeOnDisplayThread(() -> { + Shell shell = new Shell(Display.getDefault()); + try { + TextViewer viewer = new TextViewer(shell, SWT.NONE); + Document document = new Document("/rev"); + viewer.setDocument(document); + viewer.getTextWidget().setCaretOffset(document.getLength()); + + ICompletionProposal[] proposals = createProcessor(viewer, chatServiceManager) + .computeCompletionProposals(viewer, document.getLength()); + displayString.set(proposals[0].getDisplayString()); + styledDisplayString.set(((ICompletionProposalExtension6) proposals[0]).getStyledDisplayString().getString()); + proposals[0].apply(document); + insertedText.set(document.get()); + } finally { + shell.dispose(); + } + }); + + assertEquals("/review", displayString.get()); + assertEquals("/review", styledDisplayString.get()); + assertEquals("/review", insertedText.get()); + } + + private static ChatServiceManager mockChatServiceManager(ConversationTemplate... templates) { + ChatServiceManager chatServiceManager = mock(ChatServiceManager.class); + ChatCompletionService completionService = mock(ChatCompletionService.class); + UserPreferenceService userPreferenceService = mock(UserPreferenceService.class); + when(chatServiceManager.getChatCompletionService()).thenReturn(completionService); + when(chatServiceManager.getUserPreferenceService()).thenReturn(userPreferenceService); + when(userPreferenceService.getActiveChatMode()).thenReturn(ChatMode.Agent); + when(completionService.isTempaltesReady()).thenReturn(true); + when(completionService.getFilteredTemplates(ChatMode.Agent)).thenReturn(templates); + return chatServiceManager; + } + + private static IContentAssistProcessor createProcessor(TextViewer viewer, ChatServiceManager chatServiceManager) { + try { + Class processorClass = Class.forName("com.microsoft.copilot.eclipse.ui.chat.ChatAssistProcessor"); + Constructor constructor = processorClass.getDeclaredConstructor(TextViewer.class, ChatServiceManager.class); + constructor.setAccessible(true); + return (IContentAssistProcessor) constructor.newInstance(viewer, chatServiceManager); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } } diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionServiceTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionServiceTest.java index c2d3aa425..fe948a7a5 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionServiceTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionServiceTest.java @@ -31,11 +31,14 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationTemplate; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotScope; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.TemplateSource; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.preferences.LanguageServerSettingManager; class ChatCompletionServiceTest { + private static final String PROMPT_FILE_URI = "file:///c%3A/repo/.github/prompts/review.prompt.md"; + private static CopilotLanguageServerConnection mockLsConnection; private static AuthStatusManager mockAuthStatusManager; @@ -66,9 +69,11 @@ static void setUp() { platformUiMock = Mockito.mockStatic(PlatformUI.class); platformUiMock.when(PlatformUI::getWorkbench).thenReturn(mockWorkbench); - ConversationTemplate template = new ConversationTemplate("test", null, null, + ConversationTemplate template = new ConversationTemplate("test", null, null, null, List.of(CopilotScope.CHAT_PANEL), null); - ConversationTemplate[] templates = new ConversationTemplate[] { template }; + ConversationTemplate promptTemplate = new ConversationTemplate(PROMPT_FILE_URI, "review", "", "", + List.of(CopilotScope.AGENT_PANEL), TemplateSource.PROMPT); + ConversationTemplate[] templates = new ConversationTemplate[] { template, promptTemplate }; when(mockLsConnection.listConversationTemplates(any())).thenReturn(CompletableFuture.completedFuture(templates)); when(mockAuthStatusManager.getCopilotStatus()).thenReturn(CopilotStatusResult.OK); chatCompletionService = new ChatCompletionService(mockLsConnection, mockAuthStatusManager); @@ -117,6 +122,12 @@ void testIsCommand() { assertFalse(chatCompletionService.isCommand("@project")); } + @Test + void testIsCommand_promptFileUsesNameInsteadOfUri() { + assertTrue(chatCompletionService.isCommand("/review")); + assertFalse(chatCompletionService.isCommand("/" + PROMPT_FILE_URI)); + } + @Test void testGetFilteredTemplates() { assertNotNull(chatCompletionService.getFilteredTemplates(ChatMode.Ask)); diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java index d13447259..53187d1e1 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java @@ -9,6 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.times; @@ -20,8 +21,10 @@ import com.google.gson.JsonObject; import org.eclipse.core.net.proxy.IProxyData; import org.eclipse.core.net.proxy.IProxyService; +import org.eclipse.e4.core.services.events.IEventBroker; import org.eclipse.jface.preference.IPreferenceStore; import org.eclipse.lsp4j.DidChangeConfigurationParams; +import org.eclipse.ui.PlatformUI; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -29,8 +32,11 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import org.osgi.service.event.EventHandler; import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.chat.service.ICustomizationFileService.CustomizationType; +import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotLanguageServerSettings; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotLanguageServerSettings.CopilotSettings; @@ -332,4 +338,27 @@ void testUpdateProxySettingsWithProxyAndAuth() { assertEquals("HTTPS://proxy.example.com:3128", settings.getHttp().getProxy()); assertEquals("testuser:testpass", settings.getHttp().getProxyAuthorization()); } + + @Test + void testParentRepositorySettingChange_refreshesAllCustomizationTypes() { + IPreferenceStore preferenceStore = CopilotUi.getPlugin().getPreferenceStore(); + IEventBroker eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); + EventHandler handler = mock(EventHandler.class); + eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_DID_CHANGE_CUSTOMIZATION_FILES, handler); + LanguageServerSettingManager manager = new LanguageServerSettingManager(mockLsConnection, mockProxyService, + preferenceStore); + try { + preferenceStore.setValue(Constants.CUSTOM_INSTRUCTIONS_PARENT_REPO_ENABLED, false); + + for (CustomizationType type : CustomizationType.values()) { + verify(handler, timeout(5000).atLeastOnce()) + .handleEvent(argThat(event -> event.getProperty(IEventBroker.DATA) == type)); + } + } finally { + manager.unregisterPropertyChangeListener(manager); + manager.dispose(); + eventBroker.unsubscribe(handler); + preferenceStore.setToDefault(Constants.CUSTOM_INSTRUCTIONS_PARENT_REPO_ENABLED); + } + } } \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessor.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessor.java index 623261f52..ca0120c34 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessor.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessor.java @@ -105,7 +105,9 @@ public Point getSelection(IDocument document) { public StyledString getStyledDisplayString() { StyledString styledString = new StyledString(); styledString.append(triggerCharacter + displayName); - styledString.append(" - " + description, StyledString.QUALIFIER_STYLER); + if (StringUtils.isNotBlank(description)) { + styledString.append(" - " + description, StyledString.QUALIFIER_STYLER); + } return styledString; } } @@ -121,14 +123,15 @@ public ICompletionProposal[] createCopilotCompletionTemplateProposals(String pre String lowerPrefix = prefix.toLowerCase(); // Sort results by match quality, then build proposals. - return Arrays.stream(templates).filter(t -> StringUtils.isNotBlank(t.id())) + return Arrays.stream(templates).filter(t -> StringUtils.isNotBlank(t.commandName())) .map(t -> new SimpleEntry<>(t, getMatchPriority(t, lowerPrefix))) .filter(e -> e.getValue() >= 0).sorted(Comparator.comparingInt(Entry::getValue)).map(e -> { ConversationTemplate t = e.getKey(); boolean isSkill = t.source() == TemplateSource.SKILL; - String displayName = isSkill && StringUtils.isNotBlank(t.shortDescription()) ? t.shortDescription() : t.id(); - return (ICompletionProposal) new ChatCompletionProposal(ChatCompletionService.TEMPLATE_MARK, t.id(), - displayName, t.description()); + String displayName = isSkill && StringUtils.isNotBlank(t.shortDescription()) ? t.shortDescription() + : t.commandName(); + return (ICompletionProposal) new ChatCompletionProposal(ChatCompletionService.TEMPLATE_MARK, + t.commandName(), displayName, t.description()); }).toArray(ICompletionProposal[]::new); } @@ -137,8 +140,8 @@ public ICompletionProposal[] createCopilotCompletionTemplateProposals(String pre * or -1 if it does not match at all. * *

Priority buckets: - * 0 – id starts with prefix (or prefix is empty) - * 1 – id contains prefix (or skill shortDescription contains prefix) + * 0 – command name starts with prefix (or prefix is empty) + * 1 – command name contains prefix (or skill shortDescription contains prefix) * 2 – description starts with prefix * 3 – description contains prefix */ @@ -147,13 +150,13 @@ private int getMatchPriority(ConversationTemplate template, String lowerPrefix) return 0; } boolean isSkill = template.source() == TemplateSource.SKILL; - String id = template.id() != null ? template.id().toLowerCase() : ""; + String command = template.commandName() != null ? template.commandName().toLowerCase() : ""; String desc = template.description() != null ? template.description().toLowerCase() : ""; String shortDesc = template.shortDescription() != null ? template.shortDescription().toLowerCase() : ""; - if (id.startsWith(lowerPrefix)) { + if (command.startsWith(lowerPrefix)) { return 0; - } else if (id.contains(lowerPrefix) || (isSkill && shortDesc.contains(lowerPrefix))) { + } else if (command.contains(lowerPrefix) || (isSkill && shortDesc.contains(lowerPrefix))) { return 1; } else if (desc.startsWith(lowerPrefix)) { return 2; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java index 29b8eb3d4..40c91e5a6 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java @@ -83,6 +83,7 @@ import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData; import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; import com.microsoft.copilot.eclipse.core.persistence.UserTurnData; +import com.microsoft.copilot.eclipse.core.utils.WorkspaceUtils; import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool; import com.microsoft.copilot.eclipse.terminal.api.TerminalServiceManager; import com.microsoft.copilot.eclipse.ui.CopilotUi; @@ -1229,8 +1230,8 @@ private void onSendInternal(String workDoneToken, String message, String agentSl } List deriveWorkspaceFolders(IFile currentFile, List references) { - String chatInstrScope = CopilotUi.getPlugin().getPreferenceStore().getString( - Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE); + IPreferenceStore preferenceStore = CopilotUi.getPlugin().getPreferenceStore(); + String chatInstrScope = preferenceStore.getString(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE); CustomInstructionsChatLoadScope scope; try { scope = CustomInstructionsChatLoadScope.fromValue(chatInstrScope); @@ -1239,7 +1240,7 @@ List deriveWorkspaceFolders(IFile currentFile, List "Failed parsing custom instructions load scope for chat preference, using default value", e); scope = CustomInstructionsChatLoadScope.DEFAULT_VALUE; } - return switch (scope) { + List workspaceFolders = switch (scope) { // take all projects from Eclipse workspace case ALL_PROJECTS -> LSPEclipseUtils.getWorkspaceFolders(); @@ -1247,6 +1248,7 @@ List deriveWorkspaceFolders(IFile currentFile, List case REFERENCED_PROJECTS -> ResourceUtils.deriveWorkspaceFoldersFrom( Stream.concat(references.stream(), Stream.of(currentFile)).toList()); }; + return WorkspaceUtils.withParentRepositoryFoldersIfEnabled(workspaceFolders); } /** diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java index d1500745c..427954709 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java @@ -31,6 +31,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotScope; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.TemplateSource; +import com.microsoft.copilot.eclipse.core.utils.WorkspaceUtils; import com.microsoft.copilot.eclipse.ui.utils.PreferencesUtils; /** @@ -103,7 +104,8 @@ private void initConversationTemplates(IProgressMonitor monitor) { // Pass workspace folders so the language server returns workspace-specific // prompt files (.prompt.md) and skills (SKILL.md) alongside built-in templates. try { - List workspaceFolders = LSPEclipseUtils.getWorkspaceFolders(); + List workspaceFolders = + WorkspaceUtils.withParentRepositoryFoldersIfEnabled(LSPEclipseUtils.getWorkspaceFolders()); ConversationTemplate[] rawTemplates = this.lsConnection.listConversationTemplates(workspaceFolders).get(); if (monitor.isCanceled()) { return; @@ -112,9 +114,9 @@ private void initConversationTemplates(IProgressMonitor monitor) { if (!skillsEnabled && template.source() == TemplateSource.SKILL) { continue; } - if (!EXCLUDED_COMMANDS.contains(template.id())) { + if (!EXCLUDED_COMMANDS.contains(template.commandName())) { newTemplates.add(template); - newCommands.add(TEMPLATE_MARK + template.id()); + newCommands.add(TEMPLATE_MARK + template.commandName()); } } } catch (InterruptedException | ExecutionException e) { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java index 639afd4c2..e8869947c 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java @@ -36,6 +36,7 @@ public void initializeDefaultPreferences() { pref.setDefault(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE, ""); pref.setDefault(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, CustomInstructionsChatLoadScope.DEFAULT_VALUE.getValue()); + pref.setDefault(Constants.CUSTOM_INSTRUCTIONS_PARENT_REPO_ENABLED, true); pref.setDefault(Constants.AUTO_BREAKPOINT_RESPONSE, false); pref.setDefault(Constants.MCP, """ { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java index 79b822343..77ba86c26 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java @@ -3,7 +3,10 @@ package com.microsoft.copilot.eclipse.ui.preferences; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Arrays; +import java.util.List; import org.apache.commons.lang3.StringUtils; import org.eclipse.core.resources.IFile; @@ -16,10 +19,14 @@ import org.eclipse.jface.dialogs.MessageDialog; import org.eclipse.jface.layout.GridDataFactory; import org.eclipse.jface.preference.BooleanFieldEditor; +import org.eclipse.jface.preference.FieldEditor; import org.eclipse.jface.preference.FieldEditorPreferencePage; import org.eclipse.jface.preference.IPreferenceStore; import org.eclipse.jface.preference.StringFieldEditor; import org.eclipse.jface.text.ITextViewer; +import org.eclipse.jface.util.PropertyChangeEvent; +import org.eclipse.lsp4j.WorkspaceFolder; +import org.eclipse.osgi.util.NLS; import org.eclipse.swt.SWT; import org.eclipse.swt.custom.StyledText; import org.eclipse.swt.events.SelectionListener; @@ -46,6 +53,8 @@ import com.microsoft.copilot.eclipse.core.Constants; import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope; +import com.microsoft.copilot.eclipse.core.utils.FileUtils; +import com.microsoft.copilot.eclipse.core.utils.WorkspaceUtils; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.utils.PreferencesUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; @@ -61,6 +70,9 @@ public class CustomInstructionPreferencePage extends FieldEditorPreferencePage i private StringFieldEditor workspaceInstrField; private StringFieldEditor gitCommitInstrField; private Combo chatInstrLoadScopeCombo; + private BooleanFieldEditor parentRepoInstrField; + private Table projectInstrTable; + private Button projectInstrEditButton; private static final CustomInstructionsChatLoadScope[] SCOPES = CustomInstructionsChatLoadScope.values(); @@ -69,6 +81,7 @@ public class CustomInstructionPreferencePage extends FieldEditorPreferencePage i private String initialWorkspaceInstructions; private String initialGitCommitInstructions; private CustomInstructionsChatLoadScope initialChatCustomInstrLoadScope; + private boolean initialParentRepoEnabled; private static final String GITHUB = ".github"; private static final String COPILOT_INSTRUCTIONS = "copilot-instructions.md"; @@ -116,6 +129,7 @@ private void initializePreferenceValues() { initialWorkspaceEnabled = getPreferenceStore().getBoolean(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE_ENABLED); initialWorkspaceInstructions = getPreferenceStore().getString(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE); initialGitCommitInstructions = getPreferenceStore().getString(Constants.CUSTOM_INSTRUCTIONS_GIT_COMMIT); + initialParentRepoEnabled = getPreferenceStore().getBoolean(Constants.CUSTOM_INSTRUCTIONS_PARENT_REPO_ENABLED); initialChatCustomInstrLoadScope = PreferencesUtils.getCustomInstructionsChatLoadScope(getPreferenceStore()); updateChatInstrLoadScopeComboSelection(false); @@ -131,11 +145,13 @@ private boolean hasPreferencesChanged() { String currentWorkspaceInstructions = workspaceInstrField.getStringValue(); String currentGitCommitInstructions = gitCommitInstrField.getStringValue(); CustomInstructionsChatLoadScope currentCustomInstrLoadScope = getSelectedCustomInstrLoadScope(); + boolean currentParentRepoEnabled = parentRepoInstrField.getBooleanValue(); return currentWorkspaceEnabled != initialWorkspaceEnabled || !StringUtils.equals(currentWorkspaceInstructions, initialWorkspaceInstructions) || !StringUtils.equals(currentGitCommitInstructions, initialGitCommitInstructions) - || !initialChatCustomInstrLoadScope.equals(currentCustomInstrLoadScope); + || !initialChatCustomInstrLoadScope.equals(currentCustomInstrLoadScope) + || currentParentRepoEnabled != initialParentRepoEnabled; } @Override @@ -144,6 +160,7 @@ public boolean performOk() { initialWorkspaceEnabled = enableWorkspaceInstrField.getBooleanValue(); initialWorkspaceInstructions = workspaceInstrField.getStringValue(); initialGitCommitInstructions = gitCommitInstrField.getStringValue(); + initialParentRepoEnabled = parentRepoInstrField.getBooleanValue(); initialChatCustomInstrLoadScope = getSelectedCustomInstrLoadScope(); getPreferenceStore().setValue(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, @@ -158,6 +175,15 @@ protected void performDefaults() { super.performDefaults(); updateChatInstrLoadScopeComboSelection(true); + populateProjectTable(parentRepoInstrField.getBooleanValue()); + } + + @Override + public void propertyChange(PropertyChangeEvent event) { + super.propertyChange(event); + if (event.getSource() == parentRepoInstrField && FieldEditor.VALUE.equals(event.getProperty())) { + populateProjectTable(parentRepoInstrField.getBooleanValue()); + } } private void updateChatInstrLoadScopeComboSelection(boolean useDefaultValue) { @@ -271,7 +297,8 @@ private void createProjectInstructionsField(Composite parent, GridLayout gl) { SwtUtils.resizeColumnToFillTable(table, fileLocationColumn, 100, projectNameColumn); // Populate table with actual workspace projects - populateProjectTable(table); + projectInstrTable = table; + populateProjectTable(getPreferenceStore().getBoolean(Constants.CUSTOM_INSTRUCTIONS_PARENT_REPO_ENABLED)); // Create edit button createButton(tableContainer, table); @@ -299,6 +326,16 @@ private void createProjectInstructionsField(Composite parent, GridLayout gl) { chatInstrLoadScopeCombo.setItems(items); chatInstrLoadScopeCombo.setToolTipText(Messages.preferences_page_custom_instructions_chat_load_scope_combo_tooltip); chatInstrLoadScopeCombo.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + + // add check box for also loading custom instructions from the parent git repositories of the projects + Composite parentRepoInstrContainer = new Composite(projectInstrGroup, SWT.NONE); + parentRepoInstrContainer.setLayout(new GridLayout(1, false)); + parentRepoInstrContainer.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + parentRepoInstrField = new BooleanFieldEditor(Constants.CUSTOM_INSTRUCTIONS_PARENT_REPO_ENABLED, + Messages.preferences_page_custom_instructions_include_parent_repository, parentRepoInstrContainer); + parentRepoInstrField.getDescriptionControl(parentRepoInstrContainer) + .setToolTipText(Messages.preferences_page_custom_instructions_include_parent_repository_tooltip); + addField(parentRepoInstrField); } private void createGitCommitInstructionsField(Composite parent, GridLayout gl) { @@ -354,6 +391,7 @@ private void createButton(Composite tableContainer, Table table) { // Initially disable buttons if no selection editButton.setEnabled(false); + projectInstrEditButton = editButton; } private void handleEditButtonClick(Table table) { @@ -362,14 +400,12 @@ private void handleEditButtonClick(Table table) { return; } - String projectName = table.getItem(selectionIndex).getText(0); - IProject project = ResourcesPlugin.getWorkspace().getRoot().getProject(projectName); - - if (project == null || !project.exists()) { - return; + Object data = table.getItem(selectionIndex).getData(); + if (data instanceof IProject project && project.exists()) { + openInstructionFile(project, project.getName()); + } else if (data instanceof Path instructionFile && UiUtils.openLocalFileInEditor(instructionFile) != null) { + promptToClosePreferencePage(); } - - openInstructionFile(project, projectName); } private void openInstructionFile(IProject project, String projectName) { @@ -491,16 +527,24 @@ private void promptToClosePreferencePage() { } } - private void createTableItem(Table table, String projectName, String fileLocation) { + private void createTableItem(Table table, String projectName, String fileLocation, Object data) { TableItem item = new TableItem(table, SWT.NONE); item.setText(0, projectName); item.setText(1, fileLocation); + item.setData(data); } /** - * Populates the table with actual workspace projects that have copilot instructions files. + * Populates the table with actual workspace projects that have copilot instructions files, and optionally with the + * folders of their parent git repositories that have copilot instructions files. */ - private void populateProjectTable(Table table) { + private void populateProjectTable(boolean includeParentRepositories) { + Table table = projectInstrTable; + table.removeAll(); + if (projectInstrEditButton != null) { + projectInstrEditButton.setEnabled(false); + } + IProject[] projects = ResourcesPlugin.getWorkspace().getRoot().getProjects(); for (IProject project : projects) { if (project.exists() && project.isOpen()) { @@ -512,10 +556,28 @@ private void populateProjectTable(Table table) { // Only add projects to the table if the copilot-instructions.md file actually exists if (instructionFilePath.toFile().exists()) { - createTableItem(table, projectName, projectLocation.toOSString()); + createTableItem(table, projectName, projectLocation.toOSString(), project); } } } } + + if (!includeParentRepositories) { + return; + } + List parentFolders = + WorkspaceUtils.listParentRepositoryFolders(WorkspaceUtils.listWorkspaceFolders()); + for (WorkspaceFolder folder : parentFolders) { + Path folderPath = FileUtils.getLocalFilePath(folder.getUri()); + if (folderPath == null) { + continue; + } + Path instructionFile = folderPath.resolve(GITHUB).resolve(COPILOT_INSTRUCTIONS); + if (Files.exists(instructionFile)) { + createTableItem(table, + NLS.bind(Messages.preferences_page_parent_repository_folder, folder.getName()), + folderPath.toString(), instructionFile); + } + } } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomModesPreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomModesPreferencePage.java index b6a98e20a..41a7807c2 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomModesPreferencePage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomModesPreferencePage.java @@ -177,7 +177,7 @@ private void loadModes() { for (CustomChatMode mode : customModes) { TableItem item = new TableItem(modesTable, SWT.NONE); item.setText(0, mode.getDisplayName()); - item.setText(1, getWorkspaceNameForMode(mode)); + item.setText(1, PreferencePageUtils.getCustomAgentFolderName(mode)); item.setText(2, mode.getDescription() != null ? mode.getDescription() : ""); item.setData(mode); } @@ -185,29 +185,6 @@ private void loadModes() { modesTable.update(); } - /** - * Get the workspace name for a custom agent based on its file path. - */ - private String getWorkspaceNameForMode(CustomChatMode mode) { - try { - String modeId = mode.getId(); - Path modePath = Paths.get(java.net.URI.create(modeId)); - - List workspaceFolders = WorkspaceUtils.listWorkspaceFolders(); - if (workspaceFolders != null) { - for (WorkspaceFolder folder : workspaceFolders) { - Path folderPath = Paths.get(java.net.URI.create(folder.getUri())); - if (modePath.startsWith(folderPath)) { - return folder.getName(); - } - } - } - } catch (Exception e) { - CopilotCore.LOGGER.error("Failed to get workspace name for mode", e); - } - return ""; - } - /** * Handle adding a new mode. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java index 525c4bbca..4a8ebaf51 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java @@ -31,6 +31,7 @@ import com.microsoft.copilot.eclipse.core.chat.CustomChatModeManager; import com.microsoft.copilot.eclipse.core.chat.FileOperationAutoApproveRule; import com.microsoft.copilot.eclipse.core.chat.TerminalAutoApproveRule; +import com.microsoft.copilot.eclipse.core.chat.service.ICustomizationFileService.CustomizationType; import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; import com.microsoft.copilot.eclipse.core.lsp.mcp.McpServerToolsStatusCollection; @@ -182,6 +183,11 @@ public void propertyChange(PropertyChangeEvent event) { singleSetting = updateWorkspaceInstructionEnabled( preferenceStore.getBoolean(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE_ENABLED)); break; + case Constants.CUSTOM_INSTRUCTIONS_PARENT_REPO_ENABLED: + for (CustomizationType type : CustomizationType.values()) { + eventBroker.post(CopilotEventConstants.TOPIC_CHAT_DID_CHANGE_CUSTOMIZATION_FILES, type); + } + return; case Constants.CUSTOM_INSTRUCTIONS_GIT_COMMIT: String gitCommitInstructions = preferenceStore.getString(Constants.CUSTOM_INSTRUCTIONS_GIT_COMMIT); settings.getGithubSettings().setGitCommitCopilotInstructions(gitCommitInstructions); @@ -416,7 +422,7 @@ private void updateMcpToolsStatus(String mcpToolsStatus, String modeId) { UpdateMcpToolsStatusParams mcpParams = new UpdateMcpToolsStatusParams(); List serverList = new ArrayList<>(); mcpParams.setServers(serverList); - mcpParams.setWorkspaceFolders(WorkspaceUtils.listWorkspaceFolders()); + mcpParams.setWorkspaceFolders(WorkspaceUtils.listCustomizationFolders()); // Set custom mode ID only if this is for a custom mode (ID starts with "file://") // For built-in agent mode, customChatModeId should not be set @@ -469,7 +475,7 @@ private void updateMcpToolsStatus(String mcpToolsStatus, String modeId) { if (builtInTools != null && !builtInTools.isEmpty()) { UpdateConversationToolsStatusParams conversationParams = new UpdateConversationToolsStatusParams(); conversationParams.setChatModeKind("Agent"); - conversationParams.setWorkspaceFolders(WorkspaceUtils.listWorkspaceFolders()); + conversationParams.setWorkspaceFolders(WorkspaceUtils.listCustomizationFolders()); // Set custom mode ID only if this is for a custom mode (ID starts with "file://") // For built-in agent mode, customChatModeId should not be set diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java index c4424b7b6..6ac6e33a1 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java @@ -7,8 +7,6 @@ import java.io.StringReader; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -35,7 +33,6 @@ import org.eclipse.jface.preference.IPreferenceStore; import org.eclipse.jface.preference.PreferenceDialog; import org.eclipse.jface.preference.StringFieldEditor; -import org.eclipse.lsp4j.WorkspaceFolder; import org.eclipse.osgi.util.NLS; import org.eclipse.swt.SWT; import org.eclipse.swt.events.SelectionAdapter; @@ -69,7 +66,6 @@ import com.microsoft.copilot.eclipse.core.lsp.mcp.McpServerToolsCollection; import com.microsoft.copilot.eclipse.core.lsp.mcp.RegistryAccess; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; -import com.microsoft.copilot.eclipse.core.utils.WorkspaceUtils; import com.microsoft.copilot.eclipse.ui.CopilotImages; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.services.McpExtensionPointManager; @@ -93,6 +89,8 @@ public class McpPreferencePage extends FieldEditorPreferencePage implements IWor private Tree toolsTree; private boolean hasFailedMcpServer; private Combo modeSelector; + // mode IDs in the order of the mode selector entries + private final List modeSelectorIds = new ArrayList<>(); private Composite modeSelectorComposite; private String currentModeId = "agent-mode"; @@ -948,19 +946,22 @@ public void widgetSelected(SelectionEvent e) { */ private void loadModeOptions() { List options = new ArrayList<>(); + modeSelectorIds.clear(); options.add(Messages.preferences_page_mcp_tools_agent_mode); + modeSelectorIds.add("agent-mode"); // Add custom agents try { List customModes = CustomChatModeManager.INSTANCE.getCustomModes(); for (CustomChatMode mode : customModes) { - String workspaceName = getWorkspaceNameForMode(mode); + String workspaceName = PreferencePageUtils.getCustomAgentFolderName(mode); if (workspaceName.isEmpty()) { CopilotCore.LOGGER.info("Workspace name is empty for custom agent: " + mode.getDisplayName() + " (ID: " + mode.getId() + ")"); continue; } options.add(workspaceName + ": " + mode.getDisplayName()); + modeSelectorIds.add(mode.getId()); } } catch (Exception e) { CopilotCore.LOGGER.error("Failed to load custom agents", e); @@ -983,22 +984,15 @@ private void selectModeById(String modeId) { if ("agent-mode".equals(modeId)) { modeSelector.select(0); } else { - // Find the custom agent - try { - List customModes = CustomChatModeManager.INSTANCE.getCustomModes(); - for (int i = 0; i < customModes.size(); i++) { - if (customModes.get(i).getId().equals(modeId)) { - modeSelector.select(i + 1); // +1 because agent mode is at index 0 - currentModeId = modeId; - // Load the tool status for this mode - if (toolsTree != null && !toolsTree.isDisposed()) { - loadModeToolStatus(modeId); - } - return; - } + int index = modeSelectorIds.indexOf(modeId); + if (index > 0) { + modeSelector.select(index); + currentModeId = modeId; + // Load the tool status for this mode + if (toolsTree != null && !toolsTree.isDisposed()) { + loadModeToolStatus(modeId); } - } catch (Exception e) { - CopilotCore.LOGGER.error("Failed to select mode by ID: " + modeId, e); + return; } // If mode not found, default to agent mode @@ -1015,79 +1009,15 @@ private void onModeChanged() { saveModeToolStatus(currentModeId); // Update current mode ID - String selectedText = modeSelector.getText(); - currentModeId = extractModeIdFromSelection(selectedText); + int selectionIndex = modeSelector.getSelectionIndex(); + currentModeId = selectionIndex >= 0 && selectionIndex < modeSelectorIds.size() + ? modeSelectorIds.get(selectionIndex) + : "agent-mode"; // Load the tool status for the new mode (updates checkboxes on existing tree) loadModeToolStatus(currentModeId); } - /** - * Extract mode ID from selection text. - */ - private String extractModeIdFromSelection(String selectionText) { - if (selectionText.equals(Messages.preferences_page_mcp_tools_agent_mode)) { - return "agent-mode"; - } else { - // Extract workspace name and display name from the selection text - // Format: "workspace: displayName" - String workspaceName; - String displayName; - - if (selectionText.contains(": ")) { - int colonIndex = selectionText.indexOf(": "); - workspaceName = selectionText.substring(0, colonIndex); - displayName = selectionText.substring(colonIndex + 2); - } else { - CopilotCore.LOGGER.info("Invalid mode selection format (missing ': '): " + selectionText); - return "agent-mode"; - } - - try { - List customModes = CustomChatModeManager.INSTANCE.getCustomModes(); - for (CustomChatMode mode : customModes) { - // Match both display name and workspace name to ensure uniqueness - if (mode.getDisplayName().equals(displayName)) { - String modeWorkspaceName = getWorkspaceNameForMode(mode); - if (workspaceName.equals(modeWorkspaceName)) { - return mode.getId(); - } - } - } - } catch (Exception e) { - CopilotCore.LOGGER.error("Failed to extract mode ID", e); - } - } - return "agent-mode"; - } - - /** - * Get the workspace name for a custom agent based on its file path. - */ - private String getWorkspaceNameForMode(CustomChatMode mode) { - try { - String modeId = mode.getId(); - Path modePath = Paths.get(java.net.URI.create(modeId)); - - List workspaceFolders = WorkspaceUtils.listWorkspaceFolders(); - if (workspaceFolders != null) { - for (WorkspaceFolder folder : workspaceFolders) { - try { - Path folderPath = Paths.get(java.net.URI.create(folder.getUri())); - if (modePath.startsWith(folderPath)) { - return folder.getName(); - } - } catch (Exception folderEx) { - CopilotCore.LOGGER.error("Failed to process folder uri=" + folder.getUri(), folderEx); - } - } - } - } catch (Exception e) { - CopilotCore.LOGGER.error("Failed to get workspace name for mode id=" + mode.getId(), e); - } - return ""; - } - /** * Load per-mode tool status from preferences. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java index ae4051b53..536d9d93e 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java @@ -96,6 +96,7 @@ public class Messages extends NLS { public static String preferences_page_custom_instructions_project_table_fileLocation; public static String preferences_page_custom_instructions_project_table_editButton; public static String preferences_page_custom_instructions_project_table_note; + public static String preferences_page_parent_repository_folder; public static String preferences_page_custom_instructions_project_editDialog_title; public static String preferences_page_custom_instructions_project_editDialog_message; public static String preferences_page_custom_instructions_project_editDialog_button_close; @@ -109,6 +110,8 @@ public class Messages extends NLS { public static String preferences_page_custom_instructions_chat_load_scope_all; public static String preferences_page_custom_instructions_chat_load_scope_referenced; public static String preferences_page_custom_instructions_chat_load_scope_combo_tooltip; + public static String preferences_page_custom_instructions_include_parent_repository; + public static String preferences_page_custom_instructions_include_parent_repository_tooltip; public static String preferences_page_note_prefix; public static String preferences_page_note_content; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/PreferencePageUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/PreferencePageUtils.java index 13ed7ea91..cdab146d7 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/PreferencePageUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/PreferencePageUtils.java @@ -4,9 +4,15 @@ package com.microsoft.copilot.eclipse.ui.preferences; import java.net.MalformedURLException; +import java.net.URI; import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; import java.util.function.Consumer; +import org.eclipse.lsp4j.WorkspaceFolder; +import org.eclipse.osgi.util.NLS; import org.eclipse.swt.SWT; import org.eclipse.swt.events.SelectionAdapter; import org.eclipse.swt.events.SelectionEvent; @@ -19,6 +25,8 @@ import org.eclipse.ui.dialogs.PreferencesUtil; import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.CustomChatMode; +import com.microsoft.copilot.eclipse.core.utils.WorkspaceUtils; /** * Utility class for Copilot preference pages. @@ -108,4 +116,40 @@ private static void openUrlInBrowser(SelectionEvent event) { private static void openPreferencePage(Shell shell, String preferenceId, SelectionEvent event) { PreferencesUtil.createPreferenceDialogOn(shell, preferenceId, null, event); } + + /** + * Returns the name of the workspace folder containing the given custom agent: the name of the project, or for a + * folder of a parent git repository its name marked as parent repository. If several parent repository folders + * have the same name, the folder path is returned instead to keep them distinguishable. + * + * @param mode the custom agent + * @return the folder name, or an empty string if the agent is not located in a known folder + */ + public static String getCustomAgentFolderName(CustomChatMode mode) { + try { + Path modePath = Paths.get(URI.create(mode.getId())); + List projectFolders = WorkspaceUtils.listWorkspaceFolders(); + for (WorkspaceFolder folder : projectFolders) { + if (modePath.startsWith(Paths.get(URI.create(folder.getUri())))) { + return folder.getName(); + } + } + if (!WorkspaceUtils.isParentRepositoryEnabled()) { + return ""; + } + + List parentFolders = WorkspaceUtils.listParentRepositoryFolders(projectFolders); + for (WorkspaceFolder folder : parentFolders) { + Path folderPath = Paths.get(URI.create(folder.getUri())); + if (modePath.startsWith(folderPath)) { + long sameNameCount = parentFolders.stream().filter(f -> f.getName().equals(folder.getName())).count(); + return sameNameCount > 1 ? folderPath.toString() + : NLS.bind(Messages.preferences_page_parent_repository_folder, folder.getName()); + } + } + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to get the folder name for custom agent id=" + mode.getId(), e); + } + return ""; + } } \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties index d81ecf669..d52405103 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties @@ -87,7 +87,8 @@ preferences_page_custom_instructions_project_intro=Set custom instructions for C preferences_page_custom_instructions_project_table_projectName= Project Name preferences_page_custom_instructions_project_table_fileLocation= Project Location preferences_page_custom_instructions_project_table_editButton= Edit -preferences_page_custom_instructions_project_table_note= Only projects that are open in the workspace and contain a .github/copilot-instructions.md file are listed in this table. Remember to save the file to ensure the project instructions take effect. +preferences_page_custom_instructions_project_table_note= Only projects that are open in the workspace and contain a .github/copilot-instructions.md file are listed in this table, as well as the folders of their parent git repositories if enabled below. Remember to save the file to ensure the project instructions take effect. +preferences_page_parent_repository_folder={0} (parent repository) preferences_page_custom_instructions_project_editDialog_title=Close Preference Page preferences_page_custom_instructions_project_editDialog_message=Your changes to Workspace Instructions have not been saved. Stay here to save them, or close this page to edit project instructions without saving. preferences_page_custom_instructions_project_editDialog_button_close=Close Page @@ -98,6 +99,8 @@ preferences_page_custom_instructions_chat_load_scope_label=Load custom instructi preferences_page_custom_instructions_chat_load_scope_all=all projects in workspace preferences_page_custom_instructions_chat_load_scope_referenced=projects inferred from chat-attached files preferences_page_custom_instructions_chat_load_scope_combo_tooltip=Decide which of the custom instructions will be used in the Copilot chat. +preferences_page_custom_instructions_include_parent_repository=Also load custom instructions from the parent git repository of a project +preferences_page_custom_instructions_include_parent_repository_tooltip=Also use custom instructions, e.g. .github/copilot-instructions.md, located in the folders between a project and the root of its git repository. This is useful if only nested projects of a repository are imported into the workspace. preferences_page_custom_instructions_git_commit= Git Commit Instructions preferences_page_custom_instructions_git_commit_desc=Set custom instructions for Copilot Chat when generating commit messages. preferences_page_custom_instructions_git_commit_note= Access this feature in the Git Staging view by clicking the Copilot icon. You can find this view in the Git perspective or add it via the 'Window' > 'Show View' menu.