diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java index 52e9f3c7..a493b5d7 100644 --- a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java @@ -6,7 +6,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -32,6 +34,8 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationContextParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.CurrentEditorContext; import com.microsoft.copilot.eclipse.core.lsp.protocol.DidChangeFeatureFlagsParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FileStat; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ReadFileResult; import com.microsoft.copilot.eclipse.core.utils.FileUtils; @ExtendWith(MockitoExtension.class) @@ -83,6 +87,134 @@ void testResolveCurrentEditorSkill() throws InterruptedException, ExecutionExcep } } + @Test + void testResolveCurrentEditorSkillWithVisibleEditorUri() throws InterruptedException, ExecutionException { + // Arrange + ConversationContextParams params = new ConversationContextParams("", "", + ConversationCapabilities.CURRENT_EDITOR_SKILL); + String expectedUri = "copilot-visible-editor://current/1"; + + try (MockedStatic copilotCoreMock = Mockito.mockStatic(CopilotCore.class)) { + copilotCoreMock.when(CopilotCore::getPlugin).thenReturn(plugin); + when(plugin.getChatServiceManager()).thenReturn(chatServiceManager); + when(chatServiceManager.getReferencedFileService()).thenReturn(fileService); + when(fileService.getCurrentFile()).thenReturn(null); + when(fileService.getCurrentEditorUri()).thenReturn(expectedUri); + + // Act + CompletableFuture future = client.getConversationContext(params); + Object[] result = future.get(); + + // Assert + assertNotNull(result); + assertEquals(2, result.length); + assertEquals(CurrentEditorContext.class, result[0].getClass()); + assertEquals(expectedUri, ((CurrentEditorContext) result[0]).getUri()); + assertNull(result[1]); + } + } + + @Test + void testResolveCurrentEditorSkillWithoutFileOrVisibleEditorUri() throws InterruptedException, ExecutionException { + // Arrange + ConversationContextParams params = new ConversationContextParams("", "", + ConversationCapabilities.CURRENT_EDITOR_SKILL); + + try (MockedStatic copilotCoreMock = Mockito.mockStatic(CopilotCore.class)) { + copilotCoreMock.when(CopilotCore::getPlugin).thenReturn(plugin); + when(plugin.getChatServiceManager()).thenReturn(chatServiceManager); + when(chatServiceManager.getReferencedFileService()).thenReturn(fileService); + when(fileService.getCurrentFile()).thenReturn(null); + when(fileService.getCurrentEditorUri()).thenReturn(null); + + // Act + CompletableFuture future = client.getConversationContext(params); + Object[] result = future.get(); + + // Assert + assertNotNull(result); + assertEquals(2, result.length); + assertNull(result[0]); + assertNull(result[1]); + } + } + + @Test + void testReadFileFallsBackToCurrentEditorWhenWorkspaceFileMissing() + throws InterruptedException, ExecutionException { + // Arrange + String uri = "copilot-visible-editor://current/1"; + ReadFileResult fileResult = new ReadFileResult("workspace read failed", null); + ReadFileResult currentEditorResult = new ReadFileResult("class Example {}", null); + + try (MockedStatic copilotCoreMock = Mockito.mockStatic(CopilotCore.class); + MockedStatic fileUtilsMock = Mockito.mockStatic(FileUtils.class)) { + copilotCoreMock.when(CopilotCore::getPlugin).thenReturn(plugin); + when(plugin.getChatServiceManager()).thenReturn(chatServiceManager); + when(chatServiceManager.getReferencedFileService()).thenReturn(fileService); + when(fileService.readCurrentEditor(uri)).thenReturn(currentEditorResult); + fileUtilsMock.when(() -> FileUtils.readFileWithStats(uri)).thenReturn(fileResult); + fileUtilsMock.when(() -> FileUtils.getFileFromUri(uri)).thenReturn(null); + + // Act + ReadFileResult result = new CopilotLanguageClient(Runnable::run).readFile(uri).get(); + + // Assert + assertSame(currentEditorResult, result); + } + } + + @Test + void testReadFilePreservesFileUtilsResultWhenCurrentEditorFallbackMissing() + throws InterruptedException, ExecutionException { + // Arrange + String uri = "copilot-visible-editor://current/1"; + ReadFileResult fileResult = new ReadFileResult("workspace read failed", null); + + try (MockedStatic copilotCoreMock = Mockito.mockStatic(CopilotCore.class); + MockedStatic fileUtilsMock = Mockito.mockStatic(FileUtils.class)) { + copilotCoreMock.when(CopilotCore::getPlugin).thenReturn(plugin); + when(plugin.getChatServiceManager()).thenReturn(chatServiceManager); + when(chatServiceManager.getReferencedFileService()).thenReturn(fileService); + when(fileService.readCurrentEditor(uri)).thenReturn(null); + fileUtilsMock.when(() -> FileUtils.readFileWithStats(uri)).thenReturn(fileResult); + fileUtilsMock.when(() -> FileUtils.getFileFromUri(uri)).thenReturn(null); + + // Act + ReadFileResult result = new CopilotLanguageClient(Runnable::run).readFile(uri).get(); + + // Assert + assertSame(fileResult, result); + } + } + + @Test + void testReadFileDoesNotFallbackToCurrentEditorWhenFileFound() + throws InterruptedException, ExecutionException { + // Arrange + String uri = "file:///path/to/file.txt"; + FileStat stat = new FileStat(); + stat.setSize(18); + IFile file = mock(IFile.class); + ReadFileResult fileResult = new ReadFileResult("class Example {}", stat); + + try (MockedStatic copilotCoreMock = Mockito.mockStatic(CopilotCore.class); + MockedStatic fileUtilsMock = Mockito.mockStatic(FileUtils.class)) { + copilotCoreMock.when(CopilotCore::getPlugin).thenReturn(plugin); + when(plugin.getChatServiceManager()).thenReturn(chatServiceManager); + when(chatServiceManager.getReferencedFileService()).thenReturn(fileService); + fileUtilsMock.when(() -> FileUtils.readFileWithStats(uri)).thenReturn(fileResult); + fileUtilsMock.when(() -> FileUtils.getFileFromUri(uri)).thenReturn(file); + + // Act + ReadFileResult result = new CopilotLanguageClient(Runnable::run).readFile(uri).get(); + + // Assert + assertSame(fileResult, result); + verify(fileService, never()).readCurrentEditor(uri); + } + } + @Test void testOnDidChangeFeatureFlags() { // Arrange diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/IReferencedFileService.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/IReferencedFileService.java index d457bd1d..e35d15aa 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/IReferencedFileService.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/IReferencedFileService.java @@ -10,6 +10,8 @@ import org.eclipse.jdt.annotation.Nullable; import org.eclipse.lsp4j.Range; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ReadFileResult; + /** * Interface for managing referenced files in the Copilot chat. */ @@ -19,6 +21,22 @@ public interface IReferencedFileService { */ IFile getCurrentFile(); + /** + * Get the URI for the current editor when it is not backed by a workspace file. + */ + @Nullable + default String getCurrentEditorUri() { + return null; + } + + /** + * Read the current editor contents for a URI returned by {@link #getCurrentEditorUri()}. + */ + @Nullable + default ReadFileResult readCurrentEditor(String uri) { + return null; + } + /** * Get the referenced files that is attached by user. */ diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java index da7aaee9..b6c0eefb 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java @@ -9,7 +9,10 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.ForkJoinPool; +import org.apache.commons.lang3.StringUtils; import org.eclipse.core.resources.IFile; import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.IStatus; @@ -82,6 +85,7 @@ public class CopilotLanguageClient extends LanguageClientImpl { private WatchedFileManager watchedFileManager; private IEventBroker eventBroker; + private final Executor readFileExecutor; private static final String SIGNUP_URL = "https://github.com/github-copilot/signup"; @@ -89,6 +93,11 @@ public class CopilotLanguageClient extends LanguageClientImpl { * Constructor for CopilotLanguageClient. */ public CopilotLanguageClient() { + this(ForkJoinPool.commonPool()); + } + + CopilotLanguageClient(Executor readFileExecutor) { + this.readFileExecutor = readFileExecutor; this.eventBroker = EclipseContextFactory.getServiceContext(FrameworkUtil.getBundle(getClass()).getBundleContext()) .get(IEventBroker.class); } @@ -125,11 +134,16 @@ public CompletableFuture getConversationContext(ConversationContextPar } IFile file = fileService.getCurrentFile(); - if (file == null) { - break; + if (file != null) { + String uri = FileUtils.getResourceUri(file); + return CompletableFuture.completedFuture(new Object[] { new CurrentEditorContext(uri), null }); + } + + String uri = fileService.getCurrentEditorUri(); + if (StringUtils.isNotBlank(uri)) { + return CompletableFuture.completedFuture(new Object[] { new CurrentEditorContext(uri), null }); } - String uri = FileUtils.getResourceUri(file); - return CompletableFuture.completedFuture(new Object[] { new CurrentEditorContext(uri), null }); + break; default: break; } @@ -393,7 +407,29 @@ public void onCompressionCompleted(CompressionCompletedParams params) { */ @JsonRequest("workspace/readFile") public CompletableFuture readFile(String uri) { - return CompletableFuture.supplyAsync(() -> FileUtils.readFileWithStats(uri)); + IReferencedFileService fileService = getReferencedFileService(); + return CompletableFuture.supplyAsync(() -> { + ReadFileResult result = FileUtils.readFileWithStats(uri); + if (!shouldFallbackToCurrentEditor(uri)) { + return result; + } + + ReadFileResult currentEditorResult = fileService != null ? fileService.readCurrentEditor(uri) : null; + return currentEditorResult != null ? currentEditorResult : result; + }, readFileExecutor); + } + + private static boolean shouldFallbackToCurrentEditor(String uri) { + return FileUtils.getFileFromUri(uri) == null; + } + + private static IReferencedFileService getReferencedFileService() { + CopilotCore plugin = CopilotCore.getPlugin(); + if (plugin == null || plugin.getChatServiceManager() == null) { + return null; + } + + return plugin.getChatServiceManager().getReferencedFileService(); } /** @@ -458,4 +494,4 @@ protected IStatus run(IProgressMonitor monitor) { return super.showDocument(params); } } -} \ No newline at end of file +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ReferencedFileService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ReferencedFileService.java index e447b648..f13d6513 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ReferencedFileService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ReferencedFileService.java @@ -3,6 +3,7 @@ package com.microsoft.copilot.eclipse.ui.chat.services; +import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -35,9 +36,10 @@ import org.eclipse.ui.PlatformUI; import org.eclipse.ui.texteditor.ITextEditor; -import com.microsoft.copilot.eclipse.core.Constants; import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.chat.service.IReferencedFileService; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FileStat; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ReadFileResult; import com.microsoft.copilot.eclipse.core.utils.FileUtils; import com.microsoft.copilot.eclipse.ui.chat.ActionBar; import com.microsoft.copilot.eclipse.ui.chat.CurrentReferencedFile; @@ -48,6 +50,8 @@ */ public class ReferencedFileService extends ChatBaseService implements IReferencedFileService { + private static final String VISIBLE_EDITOR_URI_PREFIX = "copilot-visible-editor://current/"; + private IObservableValue currentFileObservable; private IObservableValue isCurrentFileVisibleObservable; private IObservableValue currentSelectionObservable; @@ -65,6 +69,10 @@ public class ReferencedFileService extends ChatBaseService implements IReference private IPartListener2 listener; private ISelectionChangedListener selectionListener; private ITextEditor currentTrackedEditor; + private final Object currentEditorLock = new Object(); + private long currentEditorSequence; + @Nullable + private VisibleEditorSnapshot currentEditorSnapshot; /** * Creates a new ReferencedFileService. @@ -111,6 +119,9 @@ public void partClosed(IWorkbenchPartReference partRef) { currentFileObservable.setValue(null); currentSelectionObservable.setValue(null); }); + clearCurrentEditorSnapshot(); + } else { + updateCurrentReferencedFile(UiUtils.getActiveEditor()); } untrackSelectionInEditor(partRef); } @@ -128,6 +139,36 @@ public IFile getCurrentFile() { return result.get(); } + @Override + @Nullable + public String getCurrentEditorUri() { + final AtomicReference result = new AtomicReference<>(); + ensureRealm(() -> { + if (!Boolean.TRUE.equals(isCurrentFileVisibleObservable.getValue())) { + result.set(null); + return; + } + synchronized (currentEditorLock) { + result.set(currentEditorSnapshot != null ? currentEditorSnapshot.uri : null); + } + }); + return result.get(); + } + + @Override + @Nullable + public ReadFileResult readCurrentEditor(String uri) { + VisibleEditorSnapshot snapshot; + synchronized (currentEditorLock) { + snapshot = currentEditorSnapshot; + } + if (snapshot == null || !snapshot.uri.equals(uri)) { + return null; + } + + return createReadFileResult(snapshot.text); + } + @Override public List getReferencedFiles() { final AtomicReference> result = new AtomicReference<>(); @@ -481,12 +522,14 @@ private void updateCurrentReferencedFile(IWorkbenchPartReference partRef) { private void updateCurrentReferencedFile(IEditorPart editorPart) { if (editorPart == null) { + clearCurrentEditorSnapshot(); updateObservable(currentFileObservable, null); return; } ITextEditor textEditor = editorPart.getAdapter(ITextEditor.class); if (textEditor == null) { + clearCurrentEditorSnapshot(); updateObservable(currentFileObservable, null); return; } @@ -495,17 +538,55 @@ private void updateCurrentReferencedFile(IEditorPart editorPart) { // be added to the current file. See: https://github.com/microsoft/copilot-eclipse/issues/884 // TODO: Support other types of editors. IDocument document = LSPEclipseUtils.getDocument(textEditor); - if (document == null || LSPEclipseUtils.toUri(document) == null) { + if (document == null) { + clearCurrentEditorSnapshot(); updateObservable(currentFileObservable, null); return; } IFile currentFile = UiUtils.getCurrentFile(); - if (FileUtils.isExcludedFromCurrentFile(currentFile)) { - currentFile = null; + if (currentFile != null) { + if (FileUtils.isExcludedFromCurrentFile(currentFile) || LSPEclipseUtils.toUri(document) == null) { + clearCurrentEditorSnapshot(); + updateObservable(currentFileObservable, null); + return; + } + + clearCurrentEditorSnapshot(); + updateObservable(currentFileObservable, currentFile); + return; } - updateObservable(currentFileObservable, currentFile); + clearCurrentFileObservable(); + updateCurrentEditorSnapshot(textEditor, document.get()); + } + + private void clearCurrentFileObservable() { + ensureRealm(() -> currentFileObservable.setValue(null)); + } + + private void updateCurrentEditorSnapshot(ITextEditor textEditor, String text) { + synchronized (currentEditorLock) { + if (currentEditorSnapshot != null && currentEditorSnapshot.editor == textEditor) { + currentEditorSnapshot = new VisibleEditorSnapshot(currentEditorSnapshot.uri, textEditor, text); + return; + } + + currentEditorSnapshot = new VisibleEditorSnapshot( + VISIBLE_EDITOR_URI_PREFIX + Long.toUnsignedString(++currentEditorSequence), textEditor, text); + } + } + + private void clearCurrentEditorSnapshot() { + synchronized (currentEditorLock) { + currentEditorSnapshot = null; + } + } + + private ReadFileResult createReadFileResult(String text) { + FileStat stat = new FileStat(); + stat.setSize(text.getBytes(StandardCharsets.UTF_8).length); + return new ReadFileResult(text, stat); } /** @@ -529,4 +610,16 @@ private void unregisterPartListener() { } } -} \ No newline at end of file + private static class VisibleEditorSnapshot { + private final String uri; + private final ITextEditor editor; + private final String text; + + VisibleEditorSnapshot(String uri, ITextEditor editor, String text) { + this.uri = uri; + this.editor = editor; + this.text = text; + } + } + +}