diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/CustomInstructionsChatLoadScopeTest.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/CustomInstructionsChatLoadScopeTest.java new file mode 100644 index 00000000..aee51a1f --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/CustomInstructionsChatLoadScopeTest.java @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope; + +class CustomInstructionsChatLoadScopeTest { + + @ParameterizedTest + @EnumSource(CustomInstructionsChatLoadScope.class) + void testStringToEnumEntryConversion(CustomInstructionsChatLoadScope enumEntry) { + String inputValue = enumEntry.getValue(); + + CustomInstructionsChatLoadScope actualResult = CustomInstructionsChatLoadScope.fromValue(inputValue); + + assertEquals(enumEntry, actualResult); + } + + @ParameterizedTest + @ValueSource(strings = { "wrongValue" }) + @NullSource + void testStringToEnumEntryConversionThrowsExceptionForWrongValues(String value) { + assertThrows(IllegalArgumentException.class, () -> CustomInstructionsChatLoadScope.fromValue(value)); + } + +} 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 7e2ae6ca..3a451cf7 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 @@ -37,6 +37,9 @@ private Constants() { public static final String CUSTOM_INSTRUCTIONS_WORKSPACE = "customInstructionsWorkspace"; public static final String CUSTOM_INSTRUCTIONS_WORKSPACE_ENABLED = "customInstructionsWorkspaceEnabled"; public static final String CUSTOM_INSTRUCTIONS_GIT_COMMIT = "customInstructionsGitCommit"; + 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 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/CustomInstructionsChatLoadScope.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/CustomInstructionsChatLoadScope.java new file mode 100644 index 00000000..4ca1cff3 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/CustomInstructionsChatLoadScope.java @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import com.microsoft.copilot.eclipse.core.Constants; + +/** + * Scope loading modes for custom instructions in GitHub Copilot chat. + * + */ +public enum CustomInstructionsChatLoadScope { + ALL_PROJECTS(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE_ALL), + REFERENCED_PROJECTS(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE_REFERENCED); + + public static final CustomInstructionsChatLoadScope DEFAULT_VALUE = CustomInstructionsChatLoadScope.ALL_PROJECTS; + + private final String value; + + CustomInstructionsChatLoadScope(String value) { + this.value = value; + } + + /** + * Returns the string value representing this enum entry for preference serialization. + * + * @return the string value for this preference setting + */ + public String getValue() { + return value; + } + + /** + * Retrieves the enum constant corresponding to the given string value if available, otherwise an + * {@link IllegalArgumentException} is thrown. + * + * @param value the string value (preference) representing an enum entry + * @return the enum entry representing the given value + * @throws IllegalArgumentException if the value does not correspond to any enum entry + */ + public static CustomInstructionsChatLoadScope fromValue(String value) { + for (CustomInstructionsChatLoadScope scope : values()) { + if (scope.getValue().equals(value)) { + return scope; + } + } + throw new IllegalArgumentException("Unknown CustomInstructionsLoadScope value: " + value); + } + +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java index 8aaa7f6d..a7b3883e 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java @@ -20,6 +20,7 @@ import org.eclipse.lsp4j.ProgressParams; import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.eclipse.lsp4j.WorkspaceFolder; import org.eclipse.lsp4j.jsonrpc.Endpoint; import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.services.LanguageServer; @@ -275,6 +276,19 @@ public CompletableFuture createConversation(String workDoneTok List files, IFile currentFile, Range currentSelection, List turns, CopilotModel activeModel, String chatModeName, String customChatModeId, List todos, String agentSlug, String agentJobWorkspaceFolder) { + return createConversation(workDoneToken, message, files, currentFile, currentSelection, turns, activeModel, + chatModeName, customChatModeId, todos, agentSlug, agentJobWorkspaceFolder, + LSPEclipseUtils.getWorkspaceFolders()); + } + + /** + * Create a conversation with the given parameters, including optional workspace folders argument. + */ + public CompletableFuture createConversation(String workDoneToken, String message, + List files, IFile currentFile, Range currentSelection, List turns, CopilotModel activeModel, + String chatModeName, String customChatModeId, List todos, String agentSlug, + String agentJobWorkspaceFolder, List workspaceFolders) { + boolean supportVision = activeModel.getCapabilities().supports().vision(); Either> messageWithImages = ChatMessageUtils .createMessageWithImages(message, FileUtils.filterFilesFrom(files), supportVision); @@ -288,7 +302,7 @@ public CompletableFuture createConversation(String workDoneTok if (StringUtils.isBlank(agentSlug)) { param.setWorkspaceFolder(PlatformUtils.getWorkspaceRootUri()); - param.setWorkspaceFolders(LSPEclipseUtils.getWorkspaceFolders()); + param.setWorkspaceFolders(workspaceFolders); param.setTodoList(todos); } else { // Set agentSlug if provided - this will modify the first turn's agentSlug @@ -333,6 +347,19 @@ public CompletableFuture addConversationTurn(String workDoneToke String message, List files, IFile currentFile, Range currentSelection, CopilotModel activeModel, String chatModeName, String customChatModeId, List todoList, String agentSlug, String agentJobWorkspaceFolder) { + return addConversationTurn(workDoneToken, conversationId, message, files, currentFile, currentSelection, + activeModel, chatModeName, customChatModeId, todoList, agentSlug, agentJobWorkspaceFolder, + LSPEclipseUtils.getWorkspaceFolders()); + } + + /** + * Create a conversation turn with the given parameters, including optional workspace folders argument. + */ + public CompletableFuture addConversationTurn(String workDoneToken, String conversationId, + String message, List files, IFile currentFile, Range currentSelection, CopilotModel activeModel, + String chatModeName, String customChatModeId, List todoList, String agentSlug, + String agentJobWorkspaceFolder, List workspaceFolders) { + boolean supportVision = activeModel.getCapabilities().supports().vision(); Either> messageWithImages = ChatMessageUtils .createMessageWithImages(message, FileUtils.filterFilesFrom(files), supportVision); @@ -346,7 +373,7 @@ public CompletableFuture addConversationTurn(String workDoneToke if (StringUtils.isBlank(agentSlug)) { param.setWorkspaceFolder(PlatformUtils.getWorkspaceRootUri()); - param.setWorkspaceFolders(LSPEclipseUtils.getWorkspaceFolders()); + param.setWorkspaceFolders(workspaceFolders); param.setTodoList(todoList); } else { param.setAgentSlug(agentSlug); diff --git a/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF index bee9b6a8..6470013f 100644 --- a/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF @@ -12,6 +12,7 @@ Require-Bundle: org.mockito.mockito-core;bundle-version="5.14.2", org.eclipse.lsp4e;bundle-version="0.18.1", org.eclipse.jdt.annotation;resolution:=optional, junit-jupiter-api;bundle-version="5.10.1", + junit-jupiter-params;bundle-version="5.10.1", org.mockito.junit-jupiter;bundle-version="5.10.2", com.microsoft.copilot.eclipse.core;bundle-version="0.15.0", com.microsoft.copilot.eclipse.ui;bundle-version="0.15.0", diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtilsTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtilsTest.java new file mode 100644 index 00000000..86831c04 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtilsTest.java @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.preference.PreferenceStore; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope; + +class PreferencesUtilsTest { + + private IPreferenceStore store; + + @BeforeEach + void setUp() { + store = new PreferenceStore(); + store.setDefault(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, + CustomInstructionsChatLoadScope.DEFAULT_VALUE.getValue()); + } + + @AfterEach + void tearDown() { + store = null; + } + + @ParameterizedTest + @EnumSource(CustomInstructionsChatLoadScope.class) + void testGetCustomInstructionsChatLoadScope_returnsStoredValue(CustomInstructionsChatLoadScope scope) { + store.setValue(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, scope.getValue()); + + CustomInstructionsChatLoadScope result = PreferencesUtils.getCustomInstructionsChatLoadScope(store); + + assertEquals(scope, result); + } + + @Test + void testGetCustomInstructionsChatLoadScope_fallsBackToDefaultInCaseOfInvalidValueInStore() { + store.setValue(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, "invalid_value"); + + CustomInstructionsChatLoadScope result = PreferencesUtils.getCustomInstructionsChatLoadScope(store); + + assertEquals(CustomInstructionsChatLoadScope.DEFAULT_VALUE, result); + assertEquals(CustomInstructionsChatLoadScope.DEFAULT_VALUE.getValue(), + store.getString(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE), + "The invalid stored value should have been corrected to the default"); + } + + @Test + void testGetCustomInstructionsChatLoadScopeDefault_returnsConfiguredDefault() { + // explicitly store varying values for InstanceScope and DefaultScope + store.setValue(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, + CustomInstructionsChatLoadScope.ALL_PROJECTS.getValue()); + store.setDefault(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, + CustomInstructionsChatLoadScope.REFERENCED_PROJECTS.getValue()); + + CustomInstructionsChatLoadScope result = PreferencesUtils.getCustomInstructionsChatLoadScopeDefault(store); + + // Should return the default value, not the stored value + assertEquals(CustomInstructionsChatLoadScope.REFERENCED_PROJECTS, result); + } + + @Test + void testGetCustomInstructionsChatLoadScopeDefault_fallsBackToValidDefaultInCaseOfInvalidValue() { + String invalidDefaultValue = "invalid_default_value"; + String instanceScopValue = CustomInstructionsChatLoadScope.REFERENCED_PROJECTS.getValue(); + store.setDefault(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, invalidDefaultValue); + store.setValue(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, instanceScopValue); + + CustomInstructionsChatLoadScope result = PreferencesUtils.getCustomInstructionsChatLoadScopeDefault(store); + + assertEquals(CustomInstructionsChatLoadScope.DEFAULT_VALUE, result); + assertEquals(invalidDefaultValue, + store.getDefaultString(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE), + "The stored DefaultScope value should not change, even if it's invalid. " + + "That is repsonsibility of a PreferenceInitializer."); + assertEquals(instanceScopValue, + store.getString(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE), + "The stored InstanceScope value should not change"); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtilsTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtilsTest.java index 4194013e..2f4abb15 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtilsTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtilsTest.java @@ -7,10 +7,16 @@ import static org.mockito.Mockito.*; import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IFolder; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResource; import org.eclipse.jface.viewers.StructuredSelection; +import org.eclipse.lsp4e.LSPEclipseUtils; +import org.eclipse.lsp4j.WorkspaceFolder; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -18,6 +24,8 @@ import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import com.microsoft.copilot.eclipse.core.utils.FileUtils; @@ -35,14 +43,30 @@ class ResourceUtilsTest { @Mock private IFolder mockFolder; + + @Mock + private IProject mockProjectA; + + @Mock + private IProject mockProjectB; private MockedStatic mockedFileUtils; + private MockedStatic mockedLspUtils; + + private static final String nameProjectA= "ProjectA"; + private static final String nameProjectB= "ProjectB"; @BeforeEach void setUp() { mockedFileUtils = mockStatic(FileUtils.class); mockedFileUtils.when(() -> FileUtils.isExcludedFromReferencedFiles(mockValidFile)).thenReturn(false); mockedFileUtils.when(() -> FileUtils.isExcludedFromReferencedFiles(mockInvalidFile)).thenReturn(true); + + mockedLspUtils = mockStatic(LSPEclipseUtils.class); + mockedLspUtils.when(() -> LSPEclipseUtils.toWorkspaceFolder(mockProjectA)) + .thenReturn(new WorkspaceFolder("file:///" + nameProjectA, nameProjectA)); + mockedLspUtils.when(() -> LSPEclipseUtils.toWorkspaceFolder(mockProjectB)) + .thenReturn(new WorkspaceFolder("file:///" + nameProjectB, nameProjectB)); } @AfterEach @@ -50,6 +74,9 @@ void tearDown() { if (mockedFileUtils != null) { mockedFileUtils.close(); } + if (mockedLspUtils != null) { + mockedLspUtils.close(); + } } @Test @@ -82,4 +109,52 @@ void testCollectValidResourcesWithMocks() { assertTrue(validResources.contains(mockFolder), "Should contain folder"); assertFalse(validResources.contains(mockInvalidFile), "Should not contain excluded file"); } + + private static Stream> provideResourcesForNeverNullTest() { + return Stream.of( + null, + List.of(), + Arrays.asList((IResource) null), + List.of(mock(IFolder.class)), + List.of(mock(IProject.class)) + ); + } + + @ParameterizedTest + @MethodSource("provideResourcesForNeverNullTest") + void testDeriveWorkspaceFoldersReturnsNeverNull(List resources) { + List result = ResourceUtils.deriveWorkspaceFoldersFrom(resources); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void testDeriveWorkspaceFoldersWithMultipleResources() { + when(mockValidFile.getProject()).thenReturn(mockProjectA); + when(mockFolder.getProject()).thenReturn(mockProjectB); + when(mockProjectA.isAccessible()).thenReturn(true); + when(mockProjectB.isAccessible()).thenReturn(true); + + List result = ResourceUtils.deriveWorkspaceFoldersFrom(List.of(mockValidFile, mockFolder)); + + assertNotNull(result); + assertFalse(result.isEmpty()); + assertEquals(2, result.size(), "Both projects from both resources should be derived as workspace folders"); + } + + @Test + void testDeriveWorkspaceFoldersDoesNotReturnDuplicates() { + when(mockValidFile.getProject()).thenReturn(mockProjectA); + when(mockFolder.getProject()).thenReturn(mockProjectA); + when(mockProjectA.isAccessible()).thenReturn(true); + when(mockProjectA.getName()).thenReturn(nameProjectA); + + List result = ResourceUtils.deriveWorkspaceFoldersFrom(List.of(mockValidFile, mockFolder)); + + assertNotNull(result); + assertFalse(result.isEmpty()); + assertEquals(1, result.size(), "Projects in derived workspaces folders should be unique, no duplicates."); + assertEquals(mockProjectA.getName(), result.get(0).getName()); + } } 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 02966b30..4ffe2ac9 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 @@ -10,6 +10,7 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import java.util.stream.Stream; import org.apache.commons.lang3.StringUtils; import org.eclipse.core.resources.IFile; @@ -21,7 +22,9 @@ import org.eclipse.e4.core.services.events.IEventBroker; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.lsp4e.LSPEclipseUtils; import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.WorkspaceFolder; import org.eclipse.swt.SWT; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; @@ -44,6 +47,7 @@ import com.microsoft.copilot.eclipse.core.chat.ChatEventsManager; import com.microsoft.copilot.eclipse.core.chat.ChatProgressListener; import com.microsoft.copilot.eclipse.core.chat.CustomChatModeManager; +import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope; import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; @@ -86,6 +90,7 @@ import com.microsoft.copilot.eclipse.ui.chat.viewers.LoadingViewer; import com.microsoft.copilot.eclipse.ui.chat.viewers.NoSubscriptionViewer; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; +import com.microsoft.copilot.eclipse.ui.utils.ResourceUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; /** @@ -957,7 +962,7 @@ private void onSendInternal(String workDoneToken, String message, String agentSl CompletableFuture addConversationFuture = ls.addConversationTurn(workDoneToken, conversationId, processedMessage, references, currentFile, currentSelection, activeModel, chatModeName, customChatModeId, - currentTodos, agentSlug, agentJobWorkspaceFolder); + currentTodos, agentSlug, agentJobWorkspaceFolder, deriveWorkspaceFolders(currentFile, references)); conversationFutures.add(addConversationFuture); addConversationFuture.thenAccept(result -> { @@ -1002,16 +1007,18 @@ private void onSendInternal(String workDoneToken, String message, String agentSl chatModeName, customChatModeId, currentFile, references); } + List workspaceFolders = deriveWorkspaceFolders(currentFile, references); CompletableFuture createConversationFuture = null; if (StringUtils.isBlank(agentSlug)) { createConversationFuture = ls.createConversation(workDoneToken, processedMessage, references, currentFile, - currentSelection, turns, activeModel, chatModeName, customChatModeId, todosToRestore, null, null); + currentSelection, turns, activeModel, chatModeName, customChatModeId, todosToRestore, null, null, + workspaceFolders); } else { // For conversations sending to agents, include agentSlug and specify the target agentJobWorkspaceFolder // Don't send todo list for agent jobs - agents manage their own todo state independently createConversationFuture = ls.createConversation(workDoneToken, processedMessage, references, currentFile, currentSelection, turns, activeModel, chatModeName, customChatModeId, null, agentSlug, - agentJobWorkspaceFolder); + agentJobWorkspaceFolder, workspaceFolders); } conversationFutures.add(createConversationFuture); @@ -1051,6 +1058,27 @@ 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); + CustomInstructionsChatLoadScope scope; + try { + scope = CustomInstructionsChatLoadScope.fromValue(chatInstrScope); + } catch (IllegalArgumentException e) { + CopilotCore.LOGGER.error( + "Failed parsing custom instructions load scope for chat preference, using default value", e); + scope = CustomInstructionsChatLoadScope.DEFAULT_VALUE; + } + return switch (scope) { + // take all projects from Eclipse workspace + case ALL_PROJECTS -> LSPEclipseUtils.getWorkspaceFolders(); + + // take only projects from selected files/folders + case REFERENCED_PROJECTS -> ResourceUtils.deriveWorkspaceFoldersFrom( + Stream.concat(references.stream(), Stream.of(currentFile)).toList()); + }; + } + /** * Align with @Workspace of vscode, because we are actually indexing the whole workspace, not a single project. * (@Project is only for IntelliJ.) 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 0fe32ff0..43aaa6bb 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 @@ -9,6 +9,7 @@ import org.eclipse.jface.preference.IPreferenceStore; import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope; import com.microsoft.copilot.eclipse.ui.CopilotUi; /** @@ -31,6 +32,8 @@ public void initializeDefaultPreferences() { pref.setDefault(Constants.AGENT_MAX_REQUESTS, 25); pref.setDefault(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE_ENABLED, false); pref.setDefault(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE, ""); + pref.setDefault(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, + CustomInstructionsChatLoadScope.DEFAULT_VALUE.getValue()); 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 137fff42..437d3aba 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,6 +3,8 @@ package com.microsoft.copilot.eclipse.ui.preferences; +import java.util.Arrays; + import org.apache.commons.lang3.StringUtils; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IProject; @@ -15,6 +17,7 @@ import org.eclipse.jface.layout.GridDataFactory; import org.eclipse.jface.preference.BooleanFieldEditor; 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.swt.SWT; @@ -25,8 +28,10 @@ import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Combo; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; import org.eclipse.swt.widgets.Shell; import org.eclipse.swt.widgets.Table; import org.eclipse.swt.widgets.TableColumn; @@ -41,7 +46,9 @@ 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.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.utils.PreferencesUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; @@ -54,11 +61,15 @@ public class CustomInstructionPreferencePage extends FieldEditorPreferencePage i private BooleanFieldEditor enableWorkspaceInstrField; private StringFieldEditor workspaceInstrField; private StringFieldEditor gitCommitInstrField; + private Combo chatInstrLoadScopeCombo; + + private static final CustomInstructionsChatLoadScope[] SCOPES = CustomInstructionsChatLoadScope.values(); // Variables to track initial preference values for change detection private boolean initialWorkspaceEnabled; private String initialWorkspaceInstructions; private String initialGitCommitInstructions; + private CustomInstructionsChatLoadScope initialChatCustomInstrLoadScope; private static final String GITHUB = ".github"; private static final String COPILOT_INSTRUCTIONS = "copilot-instructions.md"; @@ -106,6 +117,9 @@ 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); + + initialChatCustomInstrLoadScope = PreferencesUtils.getCustomInstructionsChatLoadScope(getPreferenceStore()); + updateChatInstrLoadScopeComboSelection(false); } /** @@ -117,10 +131,12 @@ private boolean hasPreferencesChanged() { boolean currentWorkspaceEnabled = enableWorkspaceInstrField.getBooleanValue(); String currentWorkspaceInstructions = workspaceInstrField.getStringValue(); String currentGitCommitInstructions = gitCommitInstrField.getStringValue(); + CustomInstructionsChatLoadScope currentCustomInstrLoadScope = getSelectedCustomInstrLoadScope(); return currentWorkspaceEnabled != initialWorkspaceEnabled || !StringUtils.equals(currentWorkspaceInstructions, initialWorkspaceInstructions) - || !StringUtils.equals(currentGitCommitInstructions, initialGitCommitInstructions); + || !StringUtils.equals(currentGitCommitInstructions, initialGitCommitInstructions) + || !initialChatCustomInstrLoadScope.equals(currentCustomInstrLoadScope); } @Override @@ -130,10 +146,53 @@ public boolean performOk() { initialWorkspaceInstructions = workspaceInstrField.getStringValue(); initialGitCommitInstructions = gitCommitInstrField.getStringValue(); + initialChatCustomInstrLoadScope = getSelectedCustomInstrLoadScope(); + getPreferenceStore().setValue(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, + initialChatCustomInstrLoadScope.getValue()); + // Call super to save preferences return super.performOk(); } + @Override + protected void performDefaults() { + super.performDefaults(); + + updateChatInstrLoadScopeComboSelection(true); + } + + private void updateChatInstrLoadScopeComboSelection(boolean useDefaultValue) { + IPreferenceStore store = getPreferenceStore(); + if (chatInstrLoadScopeCombo == null || chatInstrLoadScopeCombo.isDisposed() || store == null) { + return; + } + + CustomInstructionsChatLoadScope scope = useDefaultValue + ? PreferencesUtils.getCustomInstructionsChatLoadScopeDefault(store) + : PreferencesUtils.getCustomInstructionsChatLoadScope(store); + + // we rely here on using the enum entry order that we use in our combobox using the SCOPES array + chatInstrLoadScopeCombo.select(scope.ordinal()); + } + + private CustomInstructionsChatLoadScope getSelectedCustomInstrLoadScope() { + int selectionIndex = chatInstrLoadScopeCombo.getSelectionIndex(); + + if (selectionIndex >= 0 && selectionIndex < SCOPES.length) { + return SCOPES[selectionIndex]; + } else { + return CustomInstructionsChatLoadScope.DEFAULT_VALUE; + } + } + + private static String getCustomInstrLoadScopeLabel(CustomInstructionsChatLoadScope scope) { + return switch (scope) { + case ALL_PROJECTS -> Messages.preferences_page_custom_instructions_chat_load_scope_all; + case REFERENCED_PROJECTS -> Messages.preferences_page_custom_instructions_chat_load_scope_referenced; + }; + } + + private void createWorkspaceInstructionsField(Composite parent, GridLayout gl) { // workspace instructions group Group workspaceInstrGroup = new Group(parent, SWT.NONE); @@ -232,6 +291,26 @@ public void controlResized(org.eclipse.swt.events.ControlEvent e) { // Add note using WrappableNoteLabel new WrappableNoteLabel(projectInstrGroup, Messages.preferences_page_note_prefix + " ", Messages.preferences_page_custom_instructions_project_table_note); + + // add label and drop-down list for custom instructions loading scope options + Composite chatInstrContainer = new Composite(projectInstrGroup, SWT.NONE); + GridLayout containerLayout = new GridLayout(2, false); + containerLayout.marginWidth = 0; + containerLayout.marginHeight = 0; + chatInstrContainer.setLayout(containerLayout); + chatInstrContainer.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + + Label label = new Label(chatInstrContainer, SWT.NONE); + label.setText(Messages.preferences_page_custom_instructions_chat_load_scope_label); + label.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false)); + + chatInstrLoadScopeCombo = new Combo(chatInstrContainer, SWT.DROP_DOWN | SWT.READ_ONLY); + String[] items = Arrays.stream(SCOPES) + .map(CustomInstructionPreferencePage::getCustomInstrLoadScopeLabel) + .toArray(String[]::new); + 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)); } private void createGitCommitInstructionsField(Composite parent, GridLayout gl) { 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 72e62334..ab128386 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 @@ -110,6 +110,10 @@ public class Messages extends NLS { public static String preferences_page_custom_instructions_git_commit; public static String preferences_page_custom_instructions_git_commit_desc; public static String preferences_page_custom_instructions_git_commit_note; + public static String preferences_page_custom_instructions_chat_load_scope_label; + 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_note_prefix; public static String preferences_page_note_content; public static String preferences_page_mcpOAuth_confirmTitle; 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 4c961f58..031a67cd 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 @@ -93,6 +93,10 @@ preferences_page_custom_instructions_project_editDialog_button_close=Close Page preferences_page_custom_instructions_project_editDialog_button_stay=Stay on Page preferences_page_custom_instructions_project_file_save_reminder_title=Save Reminder preferences_page_custom_instructions_project_file_save_reminder_desc=Remember to save the file to ensure the project instructions take effect. +preferences_page_custom_instructions_chat_load_scope_label=Load custom instructions from +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_watched_files= Enable workspace context (experimental) 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. diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java index efd3a7c8..779fdc8b 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java @@ -3,6 +3,13 @@ package com.microsoft.copilot.eclipse.ui.utils; +import org.eclipse.core.runtime.preferences.DefaultScope; +import org.eclipse.core.runtime.preferences.InstanceScope; +import org.eclipse.jface.preference.IPreferenceStore; + +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.ui.preferences.ByokPreferencePage; import com.microsoft.copilot.eclipse.ui.preferences.ChatPreferencesPage; import com.microsoft.copilot.eclipse.ui.preferences.CompletionsPreferencesPage; @@ -27,4 +34,45 @@ public static String[] getAllPreferenceIds() { McpPreferencePage.ID, ByokPreferencePage.ID }; } + /** + * Returns the current value for the scope used for loading custom instructions in the chat. + * + * @param preferenceStore the preference store to read from + * @return the current setting from {@link InstanceScope} + */ + public static CustomInstructionsChatLoadScope getCustomInstructionsChatLoadScope(IPreferenceStore preferenceStore) { + return getCustomInstructionsChatLoadScopeValue(preferenceStore, false); + } + + /** + * Returns the default value for the scope used for loading custom instructions in the chat. + * + * @param preferenceStore the preference store to read from + * @return the current setting from {@link DefaultScope} + */ + public static CustomInstructionsChatLoadScope getCustomInstructionsChatLoadScopeDefault( + IPreferenceStore preferenceStore) { + return getCustomInstructionsChatLoadScopeValue(preferenceStore, true); + } + + private static CustomInstructionsChatLoadScope getCustomInstructionsChatLoadScopeValue( + IPreferenceStore preferenceStore, boolean readDefault) { + + String value = readDefault ? preferenceStore.getDefaultString(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE) + : preferenceStore.getString(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE); + + try { + return CustomInstructionsChatLoadScope.fromValue(value); + } catch (IllegalArgumentException e) { + CopilotCore.LOGGER.error("Failed to load custom instructions scope. Falling back to default value.", e); + + if (!readDefault) { + // If the stored value is invalid, use the default value instead + preferenceStore.setValue(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, + CustomInstructionsChatLoadScope.DEFAULT_VALUE.getValue()); + } + return CustomInstructionsChatLoadScope.DEFAULT_VALUE; + } + } + } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtils.java index ddcaad6f..1532ee98 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtils.java @@ -5,13 +5,17 @@ import java.util.ArrayList; import java.util.List; +import java.util.Objects; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IFolder; +import org.eclipse.core.resources.IProject; import org.eclipse.core.resources.IResource; import org.eclipse.core.runtime.IAdaptable; import org.eclipse.core.runtime.Platform; import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.lsp4e.LSPEclipseUtils; +import org.eclipse.lsp4j.WorkspaceFolder; import com.microsoft.copilot.eclipse.core.utils.FileUtils; @@ -73,6 +77,29 @@ public static SelectionStats analyzeSelection(IStructuredSelection selection) { return new SelectionStats(fileCount, folderCount, invalidCount); } + /** + * Derive workspace folders from the given list of resources by extracting their parent projects. + * Returns an unmodifiable list with distinct, accessible workspace folders. + * If the input list is null or empty, returns an empty list. + * + * @param resources list of resources from which their parent projects are used to derive the workspace folders. + * @return a never null list of workspace folders derived from the given resources. + */ + public static List deriveWorkspaceFoldersFrom(List resources) { + if (resources == null || resources.isEmpty()) { + return List.of(); + } + + return resources.stream() + .filter(Objects::nonNull) + .map(IResource::getProject) + .filter(Objects::nonNull) + .distinct() + .filter(IProject::isAccessible) + .map(LSPEclipseUtils::toWorkspaceFolder) + .toList(); + } + /** * Adapt an object to IResource. */