From 0b7ac1750d5a19da321f71cba4a011f8eb6ce66d Mon Sep 17 00:00:00 2001 From: Arne Deutsch Date: Tue, 29 Sep 2026 10:51:28 +0200 Subject: [PATCH 1/5] feat: Discover Copilot customizations in parent git repositories Eclipse projects are often nested inside a git repository, e.g. the modules of a multi-module build, while .github/copilot-instructions.md and other customization files live at the repository root. As the language server only searches the workspace folders it receives, these files were ignored unless the repository root itself was imported as a project. For every workspace folder located inside a git repository, the folders up to and including the repository root are now passed to the language server as well, similar to VS Code's chat.useCustomizationsInParentRepositories. This applies to chat requests (instructions, skills, sub-agents), slash commands, custom agents, auto-approval of customization file reads and the tool status of custom agents. A new preference on the Custom Instructions page controls the behavior and is enabled by default. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../core/utils/WorkspaceUtilsTests.java | 140 +++++++++++++++++- .../copilot/eclipse/core/Constants.java | 1 + .../service/CustomizationFileService.java | 4 +- .../service/FileBasedCustomModeService.java | 2 +- .../eclipse/core/utils/WorkspaceUtils.java | 101 ++++++++++++- .../LanguageServerSettingManagerTests.java | 27 ++++ .../copilot/eclipse/ui/chat/ChatView.java | 8 +- .../chat/services/ChatCompletionService.java | 4 +- .../CopilotPreferenceInitializer.java | 1 + .../CustomInstructionPreferencePage.java | 17 ++- .../LanguageServerSettingManager.java | 10 +- .../eclipse/ui/preferences/Messages.java | 2 + .../ui/preferences/messages.properties | 2 + 13 files changed, 307 insertions(+), 12 deletions(-) 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..8ed654722 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,127 @@ 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 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..a73ea93dd 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 @@ -51,7 +51,7 @@ public CustomizationFileService(CopilotLanguageServerConnection lsConnection) { .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 +80,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); } 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/utils/WorkspaceUtils.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/WorkspaceUtils.java index 28fce9ebf..e4b348e23 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,64 @@ 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); + 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/preferences/LanguageServerSettingManagerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java index d13447259..eb2c4223b 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,25 @@ 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); + try { + new LanguageServerSettingManager(mockLsConnection, mockProxyService, preferenceStore); + + 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 { + 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/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..ddabdd005 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; 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..bd5259481 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 @@ -61,6 +61,7 @@ public class CustomInstructionPreferencePage extends FieldEditorPreferencePage i private StringFieldEditor workspaceInstrField; private StringFieldEditor gitCommitInstrField; private Combo chatInstrLoadScopeCombo; + private BooleanFieldEditor parentRepoInstrField; private static final CustomInstructionsChatLoadScope[] SCOPES = CustomInstructionsChatLoadScope.values(); @@ -69,6 +70,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 +118,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 +134,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 +149,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, @@ -299,6 +305,15 @@ 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.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) { 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/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java index ae4051b53..2c7ff0a4b 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 @@ -109,6 +109,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/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties index d81ecf669..5650f0372 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 @@ -98,6 +98,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. From b9ab45215fa3507731756f0bcadbdd87cf2a503a Mon Sep 17 00:00:00 2001 From: Arne Deutsch Date: Tue, 29 Sep 2026 10:51:28 +0200 Subject: [PATCH 2/5] fix: Use prompt file names for slash commands The language server identifies prompt files by their file URI, but resolves a slash command by the prompt's name. The completion popup showed and inserted the URI, so selecting a prompt file did not apply it. Use the name for prompt files, and omit the separator for templates without description. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../protocol/ConversationTemplateTests.java | 57 +++++++++++++++++++ .../lsp/protocol/ConversationTemplate.java | 13 +++++ .../services/ChatCompletionServiceTest.java | 15 ++++- .../eclipse/ui/chat/ChatAssistProcessor.java | 23 ++++---- .../chat/services/ChatCompletionService.java | 4 +- 5 files changed, 98 insertions(+), 14 deletions(-) create mode 100644 com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplateTests.java 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/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.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/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/services/ChatCompletionService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java index ddabdd005..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 @@ -114,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) { From 446deb4974cd4ed3033bb13b529c786a9fdbebb0 Mon Sep 17 00:00:00 2001 From: Arne Deutsch Date: Tue, 29 Sep 2026 11:21:02 +0200 Subject: [PATCH 3/5] feat: List parent repository instructions on the Custom Instructions page The project table only listed copilot-instructions.md files located in project roots, so instructions loaded from a parent git repository were not shown. While the parent repository preference is enabled, the table now also lists the repository folders containing such a file, and Edit opens it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../core/utils/WorkspaceUtilsTests.java | 12 ++++ .../eclipse/core/utils/WorkspaceUtils.java | 13 ++++ .../CustomInstructionPreferencePage.java | 70 +++++++++++++++---- .../eclipse/ui/preferences/Messages.java | 1 + .../ui/preferences/messages.properties | 3 +- 5 files changed, 86 insertions(+), 13 deletions(-) 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 8ed654722..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 @@ -196,6 +196,18 @@ void testWithParentRepositoryFolders_keepsNonFileFolders() { 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()); 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 e4b348e23..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 @@ -192,6 +192,19 @@ public static Optional findRepositoryRoot(Path folder) { */ 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) { 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 bd5259481..5a801e771 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; @@ -62,6 +71,8 @@ public class CustomInstructionPreferencePage extends FieldEditorPreferencePage i private StringFieldEditor gitCommitInstrField; private Combo chatInstrLoadScopeCombo; private BooleanFieldEditor parentRepoInstrField; + private Table projectInstrTable; + private Button projectInstrEditButton; private static final CustomInstructionsChatLoadScope[] SCOPES = CustomInstructionsChatLoadScope.values(); @@ -164,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) { @@ -277,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); @@ -369,6 +390,7 @@ private void createButton(Composite tableContainer, Table table) { // Initially disable buttons if no selection editButton.setEnabled(false); + projectInstrEditButton = editButton; } private void handleEditButtonClick(Table table) { @@ -377,14 +399,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) { @@ -506,16 +526,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()) { @@ -527,10 +555,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_custom_instructions_project_table_parentRepository, folder.getName()), + folderPath.toString(), instructionFile); + } + } } } 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 2c7ff0a4b..793871021 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_custom_instructions_project_table_parentRepository; 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; 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 5650f0372..527dec510 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_custom_instructions_project_table_parentRepository={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 From 02cb88bc6ba73ed90996a4a2c2395e1665143eaa Mon Sep 17 00:00:00 2001 From: Arne Deutsch Date: Tue, 29 Sep 2026 11:38:46 +0200 Subject: [PATCH 4/5] fix: Address review feedback - Ignore results of superseded customization file refreshes, so a refresh started before the parent repository preference was disabled cannot restore parent repository files afterwards. - Resolve the folder of custom agents against the customization folders, so agents located in a parent repository are listed on the MCP and Custom Agents preference pages. - Set a layout on the container of the parent repository checkbox. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../CustomizationFileServiceTests.java | 63 +++++++++++++++++++ .../service/CustomizationFileService.java | 28 +++++++++ .../CustomInstructionPreferencePage.java | 1 + .../CustomModesPreferencePage.java | 2 +- .../ui/preferences/McpPreferencePage.java | 2 +- 5 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/chat/service/CustomizationFileServiceTests.java 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/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 a73ea93dd..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,6 +52,9 @@ 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 -> { @@ -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.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 5a801e771..29e39146e 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 @@ -329,6 +329,7 @@ private void createProjectInstructionsField(Composite parent, GridLayout gl) { // 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); 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..70522fe40 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 @@ -193,7 +193,7 @@ private String getWorkspaceNameForMode(CustomChatMode mode) { String modeId = mode.getId(); Path modePath = Paths.get(java.net.URI.create(modeId)); - List workspaceFolders = WorkspaceUtils.listWorkspaceFolders(); + List workspaceFolders = WorkspaceUtils.listCustomizationFolders(); if (workspaceFolders != null) { for (WorkspaceFolder folder : workspaceFolders) { Path folderPath = Paths.get(java.net.URI.create(folder.getUri())); 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..abe5a47e8 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 @@ -1069,7 +1069,7 @@ private String getWorkspaceNameForMode(CustomChatMode mode) { String modeId = mode.getId(); Path modePath = Paths.get(java.net.URI.create(modeId)); - List workspaceFolders = WorkspaceUtils.listWorkspaceFolders(); + List workspaceFolders = WorkspaceUtils.listCustomizationFolders(); if (workspaceFolders != null) { for (WorkspaceFolder folder : workspaceFolders) { try { From 2a09ad1416d7c4470ffee05e79c42416f8fedfc3 Mon Sep 17 00:00:00 2001 From: Arne Deutsch Date: Tue, 29 Sep 2026 11:58:20 +0200 Subject: [PATCH 5/5] fix: Keep custom agents distinguishable on the preference pages - The MCP page keeps the mode IDs in the order of its mode selector entries instead of reconstructing a mode from its displayed label, which was ambiguous for parent repository folders with the same name. - Custom agents located in a parent repository folder are labeled as such, or by the folder path if several of these folders have the same name. - Unregister the settings manager created by a test, and test the display and insertion of prompt file completion proposals. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ui/chat/ChatAssistProcessorTest.java | 75 +++++++++++++ .../LanguageServerSettingManagerTests.java | 6 +- .../CustomInstructionPreferencePage.java | 2 +- .../CustomModesPreferencePage.java | 25 +---- .../ui/preferences/McpPreferencePage.java | 106 +++--------------- .../eclipse/ui/preferences/Messages.java | 2 +- .../ui/preferences/PreferencePageUtils.java | 44 ++++++++ .../ui/preferences/messages.properties | 2 +- 8 files changed, 145 insertions(+), 117 deletions(-) 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/preferences/LanguageServerSettingManagerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java index eb2c4223b..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 @@ -345,9 +345,9 @@ void testParentRepositorySettingChange_refreshesAllCustomizationTypes() { 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 { - new LanguageServerSettingManager(mockLsConnection, mockProxyService, preferenceStore); - preferenceStore.setValue(Constants.CUSTOM_INSTRUCTIONS_PARENT_REPO_ENABLED, false); for (CustomizationType type : CustomizationType.values()) { @@ -355,6 +355,8 @@ void testParentRepositorySettingChange_refreshesAllCustomizationTypes() { .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); } 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 29e39146e..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 @@ -575,7 +575,7 @@ private void populateProjectTable(boolean includeParentRepositories) { Path instructionFile = folderPath.resolve(GITHUB).resolve(COPILOT_INSTRUCTIONS); if (Files.exists(instructionFile)) { createTableItem(table, - NLS.bind(Messages.preferences_page_custom_instructions_project_table_parentRepository, folder.getName()), + 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 70522fe40..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.listCustomizationFolders(); - 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/McpPreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java index abe5a47e8..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.listCustomizationFolders(); - 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 793871021..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,7 +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_custom_instructions_project_table_parentRepository; + 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; 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 527dec510..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 @@ -88,7 +88,7 @@ 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, 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_custom_instructions_project_table_parentRepository={0} (parent repository) +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