diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md new file mode 100644 index 00000000..ae46796c --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md @@ -0,0 +1,194 @@ +# Support Editing and Creating Local Files Outside the Workspace + +## Overview +Verify that Copilot Agent mode can edit and create local filesystem files that are outside the Eclipse workspace, and +that those changes are surfaced through the file change summary bar with the same review actions users expect for +workspace files. + +This covers the user-visible flow for the `insert_edit_into_file` and `create_file` tools when the target is an +absolute local path rather than an Eclipse `IFile`. + +Entry points: +- Window -> Show View -> Other... -> Copilot -> Copilot Chat -> Agent mode + +Not exercised: +- Direct unit-level invocation of the file tools. +- Workspace-file edit coverage. +- Low-level compare editor APIs; this plan verifies the Compare UI through the summary bar. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and activated. +- The user is signed in to GitHub Copilot and Agent mode is available in the Copilot Chat view. +- A writable local directory outside the Eclipse workspace is available, for example: + - Windows: `%TEMP%\\copilot-eclipse-local-file-tools` + - macOS/Linux: `/tmp/copilot-eclipse-local-file-tools` +- The local directory contains an existing text file named `existing-local-file.txt` with this content: + `before local edit` +- The local directory does not contain `created-local-file.txt` before the create-file test starts. + +--- + +## 1. Edit an existing local file outside the workspace + +### TC-001: Agent edits a local file and exposes the change in the summary bar + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in a fresh or cleared conversation. +- `existing-local-file.txt` exists outside the workspace and contains `before local edit`. + +#### Steps +1. Open **Copilot Chat** from `Window -> Show View -> Other... -> Copilot -> Copilot Chat`. +2. Switch the chat mode selector to **Agent**. +3. Send a prompt that asks Agent mode to edit the external local file by absolute path, for example: + `Edit so its entire content is exactly "after local edit".` +4. If Copilot asks for tool confirmation, approve the file edit operation. +5. Wait for the Agent turn to complete. +6. Verify the file change summary bar appears in the Chat view. +7. Verify the summary bar includes `existing-local-file.txt` and displays a local filesystem path for that file. +8. Click **View Diff** for `existing-local-file.txt`. +9. Verify the Compare editor opens and shows the original content `before local edit` against the modified content + `after local edit`. +10. Close the Compare editor. + +#### Expected Result +- Copilot completes the edit without reporting that the file is outside the workspace or cannot be edited. +- The local file on disk contains `after local edit`. +- The summary bar lists `existing-local-file.txt` even though it is not an Eclipse workspace file. +- The Compare editor opens from **View Diff** and shows the correct before/after content. +- No error dialog is shown. The Eclipse error log has no uncaught exception from `insert_edit_into_file`, local file + path handling, or compare editor creation. + +#### Key Screenshots +- [ ] **Agent edit prompt** -- Copilot Chat in Agent mode with the absolute local file path visible. +- [ ] **Summary bar after local edit** -- The changed local file appears in the file change summary bar. +- [ ] **Local file Compare editor** -- The Compare editor shows `before local edit` vs. `after local edit`. + +#### Notes on failure modes +- The edit succeeds on disk but the file is missing from the summary bar -- the local `Path` change may not be tracked + by the summary bar model. +- **View Diff** does nothing or throws an error -- local files may not be routed through the local Compare input path. +- The diff baseline shows the modified content on both sides -- the original content may not have been cached before + applying the edit. + +### TC-002: Keep clears the local file change and later edits use a new baseline + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in a fresh or cleared conversation. +- `existing-local-file.txt` exists outside the workspace and contains `before local edit`. +- Agent mode has edited `existing-local-file.txt` so it contains `after local edit`, and the file is listed in the + summary bar. + +#### Steps +1. Click **Keep** for `existing-local-file.txt` in the file change summary bar. +2. Verify the file is removed from the summary bar. +3. Send another Agent prompt to edit the same absolute file path so its entire content is exactly `second local edit`. +4. Approve the edit if prompted and wait for the turn to complete. +5. Click **View Diff** for `existing-local-file.txt`. +6. Verify the Compare editor shows `after local edit` as the original content and `second local edit` as the modified + content. + +#### Expected Result +- **Keep** accepts the current local file content and clears the tracked change. +- The next edit of the same local file starts a new diff baseline from the kept content. +- The file remains accessible through the summary bar and Compare editor after the second edit. + +#### Key Screenshots +- [ ] **After Keep** -- The summary bar no longer lists the local file. +- [ ] **Second local diff** -- The Compare editor shows the kept content as the new baseline. + +### TC-003: Undo restores the original local file content + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in a fresh or cleared conversation. +- `existing-local-file.txt` exists outside the workspace and contains `before local edit`. +- Agent mode has edited `existing-local-file.txt` so it contains `after local edit`, and the file is listed in the + summary bar. + +#### Steps +1. Click **Undo** for `existing-local-file.txt` in the file change summary bar. +2. Verify the file is removed from the summary bar. +3. Open `existing-local-file.txt` from the local filesystem and inspect its content. + +#### Expected Result +- **Undo** restores the file to the original content captured before the tracked edit. +- The file is removed from the summary bar after undo completes. +- No error dialog is shown and the Eclipse error log has no local file undo exception. + +#### Key Screenshots +- [ ] **Before Undo** -- The summary bar lists the edited local file. +- [ ] **After Undo** -- The summary bar no longer lists the local file and the file content is restored. + +--- + +## 2. Create a new local file outside the workspace + +### TC-004: Agent creates a local file and opens it from the summary bar + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- `created-local-file.txt` does not exist in the local test directory. +- Copilot Chat is open in Agent mode. + +#### Steps +1. Send a prompt that asks Agent mode to create the external local file by absolute path, for example: + `Create with the exact content "created local content".` +2. If Copilot asks for tool confirmation, approve the file create operation. +3. Wait for the Agent turn to complete. +4. Verify `created-local-file.txt` exists on disk and contains `created local content`. +5. Verify the file change summary bar lists `created-local-file.txt`. +6. Click **View Diff** for `created-local-file.txt`. +7. Verify Eclipse opens `created-local-file.txt` in an editor and shows `created local content`. + +#### Expected Result +- Copilot creates the local file without requiring it to be inside an Eclipse workspace project. +- The created file is listed in the summary bar. +- The created local file can be opened from the summary bar. +- No error dialog is shown and the Eclipse error log has no local file create or editor-open exception. + +#### Key Screenshots +- [ ] **Agent create prompt** -- Copilot Chat in Agent mode with the absolute create path visible. +- [ ] **Summary bar after local create** -- The created local file appears in the file change summary bar. +- [ ] **Created local file editor** -- The external local file opens in an editor with the created content. + +### TC-005: Undo removes a created local file + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in Agent mode. +- `created-local-file.txt` does not exist in the local test directory. +- Agent mode has created `created-local-file.txt` with content `created local content`, and the file is listed in the + summary bar. + +#### Steps +1. Click **Undo** for `created-local-file.txt` in the file change summary bar. +2. Verify the file is removed from the summary bar. +3. Verify `created-local-file.txt` no longer exists on disk. + +#### Expected Result +- **Undo** for a created local file deletes the file, matching the create-file semantics. +- The summary bar no longer lists the created file after undo completes. +- No error dialog is shown and the Eclipse error log has no local file deletion exception. + +#### Key Screenshots +- [ ] **Before created-file Undo** -- The summary bar lists `created-local-file.txt`. +- [ ] **After created-file Undo** -- The summary bar is clear and the file is absent from disk. diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBarTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBarTest.java index d44508a3..3f5a288c 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBarTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBarTest.java @@ -38,6 +38,7 @@ import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.chat.tools.ChangedFile; import com.microsoft.copilot.eclipse.ui.chat.tools.FileToolService; import com.microsoft.copilot.eclipse.ui.chat.tools.FileToolService.FileChangeProperty; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; @@ -103,7 +104,7 @@ private void setupMocks() { void testNoScrollForFewFiles() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(3, false); + Map filesMap = createMockFilesMap(3); workingSetBar.buildSummaryBarFor(filesMap); @@ -122,7 +123,7 @@ void testNoScrollForFewFiles() { void testNoScrollForExactlyMaxFiles() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(5, false); + Map filesMap = createMockFilesMap(5); workingSetBar.buildSummaryBarFor(filesMap); @@ -141,7 +142,7 @@ void testNoScrollForExactlyMaxFiles() { void testScrollCreatedForManyFiles() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(10, false); + Map filesMap = createMockFilesMap(10); workingSetBar.buildSummaryBarFor(filesMap); @@ -164,7 +165,7 @@ void testScrollCreatedForManyFiles() { void testScrollHeightHintForManyFiles() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(8, false); + Map filesMap = createMockFilesMap(8); workingSetBar.buildSummaryBarFor(filesMap); @@ -190,7 +191,7 @@ void testAllFileRowsRenderedWithScroll() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); int fileCount = 7; - Map filesMap = createMockFilesMap(fileCount, false); + Map filesMap = createMockFilesMap(fileCount); workingSetBar.buildSummaryBarFor(filesMap); @@ -215,7 +216,7 @@ void testAllFileRowsRenderedWithScroll() { void testContentAreaSetInScrolledComposite() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(8, false); + Map filesMap = createMockFilesMap(8); workingSetBar.buildSummaryBarFor(filesMap); @@ -242,7 +243,7 @@ void testContentAreaSetInScrolledComposite() { void testMinHeightSetForScrolledComposite() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(10, false); + Map filesMap = createMockFilesMap(10); workingSetBar.buildSummaryBarFor(filesMap); @@ -266,7 +267,7 @@ void testRebuildSummaryBarChangesScrollBehavior() { workingSetBar = new WorkingSetBar(parent, SWT.NONE); // First build with few files (no scroll) - Map fewFiles = createMockFilesMap(3, false); + Map fewFiles = createMockFilesMap(3); workingSetBar.buildSummaryBarFor(fewFiles); Object changedFiles1 = getFieldValue(workingSetBar, "changedFiles"); @@ -275,7 +276,7 @@ void testRebuildSummaryBarChangesScrollBehavior() { assertNull(scroll1, "No scroll should exist for 3 files"); // Rebuild with many files (should have scroll) - Map manyFiles = createMockFilesMap(10, false); + Map manyFiles = createMockFilesMap(10); workingSetBar.buildSummaryBarFor(manyFiles); Object changedFiles2 = getFieldValue(workingSetBar, "changedFiles"); @@ -294,7 +295,7 @@ void testRebuildSummaryBarChangesScrollBehavior() { void testExpandIconImageWhenExpanded() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(3, false); + Map filesMap = createMockFilesMap(3); workingSetBar.buildSummaryBarFor(filesMap); @@ -322,7 +323,7 @@ void testExpandIconImageWhenExpanded() { void testExpandIconImageWhenCollapsed() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(3, false); + Map filesMap = createMockFilesMap(3); workingSetBar.buildSummaryBarFor(filesMap); @@ -354,7 +355,7 @@ void testExpandIconImageWhenCollapsed() { void testTooltipTextWhenExpanded() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(3, false); + Map filesMap = createMockFilesMap(3); workingSetBar.buildSummaryBarFor(filesMap); @@ -395,7 +396,7 @@ void testTooltipTextWhenExpanded() { void testTooltipTextWhenCollapsed() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(5, false); + Map filesMap = createMockFilesMap(5); workingSetBar.buildSummaryBarFor(filesMap); @@ -436,7 +437,7 @@ void testTooltipTextWhenCollapsed() { void testTooltipAndImageToggleBehavior() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(4, false); + Map filesMap = createMockFilesMap(4); workingSetBar.buildSummaryBarFor(filesMap); @@ -476,7 +477,7 @@ void testTooltipContainsCorrectFileCount() { workingSetBar = new WorkingSetBar(parent, SWT.NONE); // Test with 1 file - Map oneFile = createMockFilesMap(1, false); + Map oneFile = createMockFilesMap(1); workingSetBar.buildSummaryBarFor(oneFile); Object titleBar = getFieldValue(workingSetBar, "titleBar"); @@ -488,7 +489,7 @@ void testTooltipContainsCorrectFileCount() { "Tooltip should contain 'file' (singular)"); // Test with 10 files - Map tenFiles = createMockFilesMap(10, false); + Map tenFiles = createMockFilesMap(10); workingSetBar.buildSummaryBarFor(tenFiles); titleBar = getFieldValue(workingSetBar, "titleBar"); @@ -508,7 +509,7 @@ void testTooltipContainsCorrectFileCount() { void testEmptyFilesMapDoesNotCreateChangedFiles() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map emptyMap = new LinkedHashMap<>(); + Map emptyMap = new LinkedHashMap<>(); workingSetBar.buildSummaryBarFor(emptyMap); @@ -524,11 +525,11 @@ void testEmptyFilesMapDoesNotCreateChangedFiles() { /** * Creates a map of mock files with the specified count. */ - private Map createMockFilesMap(int count, boolean isHandled) { - Map filesMap = new LinkedHashMap<>(); + private Map createMockFilesMap(int count) { + Map filesMap = new LinkedHashMap<>(); for (int i = 0; i < count; i++) { IFile mockFile = createMockFile("TestFile" + i + ".java"); - filesMap.put(mockFile, new FileChangeProperty(FileChangeType.Created)); + filesMap.put(ChangedFile.workspace(mockFile), new FileChangeProperty(FileChangeType.Created)); } return filesMap; } diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileToolTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileToolTest.java index 4bd2c9ed..428d7297 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileToolTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileToolTest.java @@ -5,10 +5,15 @@ 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.assertTrue; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.HashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -19,12 +24,14 @@ import org.eclipse.core.resources.IWorkspace; import org.eclipse.core.resources.IWorkspaceRoot; import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.lsp4j.FileChangeType; import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Shell; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; @@ -52,6 +59,9 @@ class CreateFileToolTest { @Mock private FileToolService mockFileToolService; + @TempDir + private Path tempDir; + private MockedStatic mockedCopilotUi; @BeforeEach @@ -78,6 +88,7 @@ void tearDown() throws Exception { // Clean up test project cleanupTestProject(); + FileToolCacheAccessor.clearCaches(); } private IProject setupTestProject() throws Exception { @@ -251,11 +262,92 @@ void testInvokeWithNullContentReturnsSuccessStatus() throws Exception { assertTrue(newFile.exists()); } + @Test + void testInvokeWithExternalLocalFilePathCreatesFile() throws Exception { + setupMocks(); + Path newFile = tempDir.resolve("external-file.txt"); + + Map input = new HashMap<>(); + input.put("filePath", newFile.toString()); + input.put("content", "test content"); + + CompletableFuture future = createFileTool.invoke(input, null); + LanguageModelToolResult[] results = future.get(); + + assertSuccessResult(results, "File created at"); + assertTrue(Files.exists(newFile)); + assertEquals("test content", Files.readString(newFile)); + verify(mockFileToolService).addChangedFile(ChangedFile.local(newFile), FileChangeType.Created); + } + + @Test + void testInvokeWithExternalLocalFileUriCreatesFile() throws Exception { + setupMocks(); + Path newFile = tempDir.resolve("external-file-uri.txt"); + + Map input = new HashMap<>(); + input.put("filePath", newFile.toUri().toString()); + input.put("content", "test content"); + + CompletableFuture future = createFileTool.invoke(input, null); + LanguageModelToolResult[] results = future.get(); + + assertSuccessResult(results, "File created at"); + assertTrue(Files.exists(newFile)); + assertEquals("test content", Files.readString(newFile)); + verify(mockFileToolService).addChangedFile(ChangedFile.local(newFile), FileChangeType.Created); + } + + @Test + void testOnKeepChangeWithWorkspaceFileClearsOriginalContentCache() { + IFile newFile = mock(IFile.class); + FileToolCacheAccessor.putWorkspaceFileContentCache(newFile, ""); + + createFileTool.onKeepChange(ChangedFile.workspace(newFile)); + + assertNull(FileToolCacheAccessor.getWorkspaceFileContentCache(newFile)); + } + + @Test + void testOnUndoChangeWithWorkspaceFileDeletesFileAndClearsOriginalContentCache() throws Exception { + IProject project = setupTestProject(); + IFile newFile = project.getFile("workspace-file-to-undo.txt"); + newFile.create(new java.io.ByteArrayInputStream("test content".getBytes()), true, null); + FileToolCacheAccessor.putWorkspaceFileContentCache(newFile, ""); + + createFileTool.onUndoChange(ChangedFile.workspace(newFile)); + + assertTrue(!newFile.exists()); + assertNull(FileToolCacheAccessor.getWorkspaceFileContentCache(newFile)); + } + + @Test + void testOnKeepChangeWithExternalLocalFileClearsOriginalContentCache() { + Path newFile = tempDir.resolve("external-file-to-keep.txt"); + FileToolCacheAccessor.putFileContentCache(newFile, ""); + + createFileTool.onKeepChange(ChangedFile.local(newFile)); + + assertNull(FileToolCacheAccessor.getFileContentCache(newFile)); + } + + @Test + void testOnUndoChangeWithExternalLocalFileDeletesFile() throws Exception { + Path newFile = tempDir.resolve("external-file-to-undo.txt"); + Files.writeString(newFile, "test content"); + FileToolCacheAccessor.putFileContentCache(newFile, ""); + + createFileTool.onUndoChange(ChangedFile.local(newFile)); + + assertTrue(Files.notExists(newFile)); + assertNull(FileToolCacheAccessor.getFileContentCache(newFile)); + } + @Test void testInvokeWithInvalidPathReturnsErrorStatus() throws InterruptedException, ExecutionException { // Arrange Map input = new HashMap<>(); - input.put("filePath", "/invalid/path/that/does/not/exist.txt"); + input.put("filePath", "relative/path/that/does/not/exist.txt"); input.put("content", "test content"); // Act @@ -263,7 +355,7 @@ void testInvokeWithInvalidPathReturnsErrorStatus() throws InterruptedException, LanguageModelToolResult[] results = future.get(); // Assert - assertErrorResult(results, "Error creating file"); + assertErrorResult(results, "does not exist in the workspace"); } @Test @@ -286,4 +378,26 @@ void testToolName() { * Note: CoreException and IOException scenarios are difficult to test in unit tests * without complex mocking and would be better covered by integration tests. */ + + private static final class FileToolCacheAccessor extends CreateFileTool { + private static void clearCaches() { + fileContentCache.clear(); + } + + private static void putWorkspaceFileContentCache(IFile file, String content) { + fileContentCache.put(ChangedFile.workspace(file), content); + } + + private static String getWorkspaceFileContentCache(IFile file) { + return fileContentCache.get(ChangedFile.workspace(file)); + } + + private static void putFileContentCache(Path file, String content) { + fileContentCache.put(ChangedFile.local(file), content); + } + + private static String getFileContentCache(Path file) { + return fileContentCache.get(ChangedFile.local(file)); + } + } } diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileToolTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileToolTest.java new file mode 100644 index 00000000..4ffabcbd --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileToolTest.java @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.lsp4j.FileChangeType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; + +@ExtendWith(MockitoExtension.class) +class EditFileToolTest { + + @TempDir + Path tempDir; + + @Mock + private CopilotUi mockCopilotUi; + @Mock + private ChatServiceManager mockChatServiceManager; + @Mock + private FileToolService mockFileToolService; + + private MockedStatic mockedCopilotUi; + + private void setupMocks() { + mockedCopilotUi = mockStatic(CopilotUi.class); + mockedCopilotUi.when(CopilotUi::getPlugin).thenReturn(mockCopilotUi); + when(mockCopilotUi.getChatServiceManager()).thenReturn(mockChatServiceManager); + when(mockChatServiceManager.getFileToolService()).thenReturn(mockFileToolService); + } + + @AfterEach + void tearDown() { + if (mockedCopilotUi != null) { + mockedCopilotUi.close(); + } + FileToolCacheAccessor.clearCaches(); + } + + @Test + void testInvoke_withExternalLocalFilePath_editsFile() throws Exception { + setupMocks(); + Path file = tempDir.resolve("target.txt"); + Files.writeString(file, "original"); + + LanguageModelToolResult[] results = invokeEdit(file.toString(), "updated"); + + assertSuccess(results, "updated"); + assertEquals("updated", Files.readString(file)); + verify(mockFileToolService).addChangedFile(ChangedFile.local(file), FileChangeType.Changed); + } + + @Test + void testInvoke_withExternalLocalFileUri_editsFile() throws Exception { + setupMocks(); + Path file = tempDir.resolve("target.patch"); + Files.writeString(file, "old patch content"); + + LanguageModelToolResult[] results = invokeEdit(file.toUri().toString(), "new patch content"); + + assertSuccess(results, "new patch content"); + assertEquals("new patch content", Files.readString(file)); + verify(mockFileToolService).addChangedFile(ChangedFile.local(file), FileChangeType.Changed); + } + + @Test + void testOnUndoChange_withExternalLocalFile_restoresOriginalContent() throws Exception { + setupMocks(); + Path file = tempDir.resolve("target-to-undo.txt"); + Files.writeString(file, "original"); + + EditFileTool editFileTool = new EditFileTool(); + LanguageModelToolResult[] results = invokeEdit(editFileTool, file.toString(), "updated"); + assertSuccess(results, "updated"); + + editFileTool.onUndoChange(ChangedFile.local(file)); + + assertEquals("original", Files.readString(file)); + } + + @Test + void testInvoke_createThenEditExternalLocalFile_preservesEmptyBaseline() throws Exception { + setupMocks(); + Path file = tempDir.resolve("created-then-edited.txt"); + Path normalizedPath = file.toAbsolutePath().normalize(); + + CreateFileTool createFileTool = new CreateFileTool(); + LanguageModelToolResult[] createResults = invokeCreate(createFileTool, file.toString(), "created content"); + assertSuccess(createResults, "File created at: " + normalizedPath); + assertEquals("", FileToolCacheAccessor.getFileContentCache(normalizedPath)); + + EditFileTool editFileTool = new EditFileTool(); + LanguageModelToolResult[] editResults = invokeEdit(editFileTool, file.toString(), "edited content"); + + assertSuccess(editResults, "edited content"); + assertEquals("edited content", Files.readString(file)); + assertEquals("", FileToolCacheAccessor.getFileContentCache(normalizedPath)); + } + + @Test + void testInvoke_withMissingExternalLocalFile_returnsError() throws Exception { + LanguageModelToolResult[] results = invokeEdit(tempDir.resolve("missing.txt").toString(), "updated"); + + assertNotNull(results); + assertEquals(1, results.length); + assertEquals(ToolInvocationStatus.error, results[0].getStatus()); + } + + private LanguageModelToolResult[] invokeEdit(String filePath, String code) throws Exception { + return invokeEdit(new EditFileTool(), filePath, code); + } + + private LanguageModelToolResult[] invokeEdit(EditFileTool editFileTool, String filePath, String code) + throws Exception { + Map input = new HashMap<>(); + input.put("filePath", filePath); + input.put("code", code); + input.put("explanation", "test edit"); + + return editFileTool.invoke(input, null).get(); + } + + private LanguageModelToolResult[] invokeCreate(CreateFileTool createFileTool, String filePath, String content) + throws Exception { + Map input = new HashMap<>(); + input.put("filePath", filePath); + input.put("content", content); + + return createFileTool.invoke(input, null).get(); + } + + private void assertSuccess(LanguageModelToolResult[] results, String expectedContent) throws IOException { + assertNotNull(results); + assertEquals(1, results.length); + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + assertEquals(expectedContent, results[0].getContent().get(0).getValue()); + } + + private static final class FileToolCacheAccessor extends EditFileTool { + private static void clearCaches() { + fileContentCache.clear(); + } + + private static String getFileContentCache(Path file) { + return fileContentCache.get(ChangedFile.local(file)); + } + } +} \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBar.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBar.java index cc6a8a5a..1c0780a6 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBar.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBar.java @@ -7,7 +7,6 @@ import java.util.List; import java.util.Map; -import org.eclipse.core.resources.IFile; import org.eclipse.e4.ui.services.IStylingEngine; import org.eclipse.osgi.util.NLS; import org.eclipse.swt.SWT; @@ -31,6 +30,7 @@ import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.services.ChatFontService; +import com.microsoft.copilot.eclipse.ui.chat.tools.ChangedFile; import com.microsoft.copilot.eclipse.ui.chat.tools.FileToolService; import com.microsoft.copilot.eclipse.ui.chat.tools.FileToolService.FileChangeProperty; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; @@ -70,7 +70,7 @@ public WorkingSetBar(Composite parent, int style) { * * @param filesMap a map of files and their change status */ - public void buildSummaryBarFor(Map filesMap) { + public void buildSummaryBarFor(Map filesMap) { if (filesMap == null || isDisposed()) { return; } @@ -167,7 +167,7 @@ class WorkingSetTitleBar extends Composite { private Button undoButton; private String changeFilesTitle; - public WorkingSetTitleBar(Composite parent, int style, Map filesMap) { + public WorkingSetTitleBar(Composite parent, int style, Map filesMap) { super(parent, style); GridLayout gl = new GridLayout(3, false); gl.marginWidth = 0; @@ -302,9 +302,10 @@ class ChangedFiles extends Composite { private static final int MAX_VISIBLE_FILES = 5; private final Composite contentArea; private final ScrolledComposite scrolledComposite; + private final WorkbenchLabelProvider labelProvider = new WorkbenchLabelProvider(); private List fileRows; // List to keep track of file rows - public ChangedFiles(Composite parent, int style, Map filesMap) { + public ChangedFiles(Composite parent, int style, Map filesMap) { super(parent, style); // Main layout @@ -313,6 +314,7 @@ public ChangedFiles(Composite parent, int style, Map layout.marginHeight = 0; setLayout(layout); setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + addDisposeListener(e -> labelProvider.dispose()); // Count files long fileCount = filesMap.size(); @@ -345,15 +347,14 @@ public ChangedFiles(Composite parent, int style, Map contentArea.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); } - // TODO: Should share a same instance with ReferencedFile - WorkbenchLabelProvider labelProvider = new WorkbenchLabelProvider(); fileRows = new LinkedList<>(); - for (IFile file : filesMap.keySet()) { + for (ChangedFile file : filesMap.keySet()) { if (file == null) { continue; } - Image image = labelProvider.getImage(file); + Image image = file.isWorkspaceFile() ? labelProvider.getImage(file.getWorkspaceFile()) + : PlatformUI.getWorkbench().getSharedImages().getImage(ISharedImages.IMG_OBJ_FILE); fileRows.add(new FileRow(contentArea, SWT.NONE, image, file)); } @@ -396,7 +397,7 @@ public class FileRow extends Composite { /** * Constructs a new FileRow. */ - public FileRow(Composite parent, int style, Image fileImage, IFile file) { + public FileRow(Composite parent, int style, Image fileImage, ChangedFile file) { super(parent, style); GridLayout layout = new GridLayout(2, false); @@ -434,7 +435,7 @@ public void mouseUp(MouseEvent e) { // File name (bold) Label nameLabel = new Label(fileInfo, SWT.NONE); nameLabel.setText(file.getName()); - nameLabel.setToolTipText(file.getFullPath().toString()); + nameLabel.setToolTipText(file.getDisplayPath()); nameLabel.setLayoutData(new GridData(SWT.BEGINNING, SWT.CENTER, false, false)); nameLabel.addMouseListener(new MouseAdapter() { @Override @@ -466,7 +467,7 @@ public void mouseUp(MouseEvent e) { // File path CLabel pathLabel = new CLabel(fileInfo, SWT.NONE); - pathLabel.setText(file.getFullPath().toString()); + pathLabel.setText(file.getDisplayPath()); pathLabel.setLayoutData(new GridData(SWT.BEGINNING, SWT.CENTER, true, false)); pathLabel.addMouseListener(new MouseAdapter() { @Override diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ChangedFile.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ChangedFile.java new file mode 100644 index 00000000..aed4594c --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ChangedFile.java @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.file.Path; +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.eclipse.core.resources.IFile; + +/** + * Represents a file tracked in the file change summary bar. + */ +public final class ChangedFile { + private final IFile workspaceFile; + private final Path localPath; + + private ChangedFile(IFile workspaceFile, Path localPath) { + this.workspaceFile = workspaceFile; + this.localPath = localPath; + } + + /** + * Creates a changed file entry for a workspace file. + * + * @param file the workspace file + * @return the changed file entry + */ + public static ChangedFile workspace(IFile file) { + return new ChangedFile(Objects.requireNonNull(file), null); + } + + /** + * Creates a changed file entry for a local file. + * + * @param path the local file path + * @return the changed file entry + */ + public static ChangedFile local(Path path) { + return new ChangedFile(null, normalize(path)); + } + + /** + * Returns true if this entry represents a workspace file. + * + * @return true for workspace files, false for local files + */ + public boolean isWorkspaceFile() { + return workspaceFile != null; + } + + /** + * Gets the workspace file for this entry. + * + * @return the workspace file, or null for local files + */ + public IFile getWorkspaceFile() { + return workspaceFile; + } + + /** + * Gets the local path for this entry. + * + * @return the local path, or null for workspace files + */ + public Path getLocalPath() { + return localPath; + } + + /** + * Gets the display name for this file. + * + * @return the file name + */ + public String getName() { + if (workspaceFile != null) { + return workspaceFile.getName(); + } + Path fileName = localPath.getFileName(); + return fileName == null ? localPath.toString() : fileName.toString(); + } + + /** + * Gets the display path for this file. + * + * @return the workspace path or local filesystem path + */ + public String getDisplayPath() { + if (workspaceFile != null) { + return workspaceFile.getFullPath().toString(); + } + return localPath.toString(); + } + + private static Path normalize(Path path) { + return Objects.requireNonNull(path).toAbsolutePath().normalize(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof ChangedFile other)) { + return false; + } + return Objects.equals(workspaceFile, other.workspaceFile) && Objects.equals(localPath, other.localPath); + } + + @Override + public int hashCode() { + return Objects.hash(workspaceFile, localPath); + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("workspaceFile", workspaceFile); + builder.append("localPath", localPath); + return builder.toString(); + } +} \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java index 29a807ce..c7795aeb 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java @@ -5,9 +5,13 @@ import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.util.Arrays; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -19,6 +23,7 @@ import org.eclipse.core.runtime.NullProgressMonitor; import org.eclipse.lsp4j.FileChangeType; +import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.lsp.protocol.InputSchema; import com.microsoft.copilot.eclipse.core.lsp.protocol.InputSchemaPropertyValue; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; @@ -57,7 +62,7 @@ public LanguageModelToolInformation getToolInformation() { // Set the name and description of the tool toolInfo.setName(TOOL_NAME); toolInfo.setDescription(""" - This is a tool for creating a new file in the workspace. + This is a tool for creating a new workspace file or a new file at an absolute local filesystem path. The file will be created with the specified content. """); @@ -90,34 +95,49 @@ public CompletableFuture invoke(Map i return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); } - try { - // Resolve file in workspace - IFile file = FileUtils.getFileFromPath(filePath, false); + String content = StringUtils.isBlank((String) input.get("content")) ? "" : (String) input.get("content"); + result = createFile(filePath, content); - if (file == null) { - result.setStatus(ToolInvocationStatus.error); - result.addContent("Invalid file path: " + filePath + " does not exist in the workspace."); - return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); - } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + private LanguageModelToolResult createFile(String filePath, String content) { + IFile file = FileUtils.getFileFromPath(filePath, false); + + if (file != null && file.getProject().exists()) { + return createWorkspaceFile(file, filePath, content); + } + + Path localPath = getLocalFilePath(filePath); + if (localPath != null) { + return createLocalFile(localPath, content); + } - // Check if file already exists + LanguageModelToolResult result = new LanguageModelToolResult(); + result.setStatus(ToolInvocationStatus.error); + result.addContent("Invalid file path: " + filePath + " does not exist in the workspace."); + return result; + } + + private LanguageModelToolResult createWorkspaceFile(IFile file, String filePath, String content) { + LanguageModelToolResult result = new LanguageModelToolResult(); + + try { if (file.exists()) { result.setStatus(ToolInvocationStatus.error); result.addContent("Failed: file already exists: " + filePath + ". Please use edit file tool to update."); - return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + return result; } - // Create parent folders if needed createParentFolders(file.getParent()); - // Create file with content - String content = StringUtils.isBlank((String) input.get("content")) ? "" : (String) input.get("content"); try (ByteArrayInputStream contentStream = new ByteArrayInputStream( content.getBytes(PlatformUtils.getFileCharset(file)))) { file.create(contentStream, IResource.FORCE, new NullProgressMonitor()); - cacheTheOriginalFileContent(file); + cacheTheOriginalFileContent(ChangedFile.workspace(file), StringUtils.EMPTY); } - CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(file, FileChangeType.Created); + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(ChangedFile.workspace(file), + FileChangeType.Created); file.refreshLocal(IResource.DEPTH_ZERO, new NullProgressMonitor()); result.addContent("File created at: " + file.getFullPath().toOSString()); @@ -130,7 +150,36 @@ public CompletableFuture invoke(Map i result.addContent("Error handling file stream: " + e.getMessage()); } - return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + return result; + } + + private LanguageModelToolResult createLocalFile(Path filePath, String content) { + LanguageModelToolResult result = new LanguageModelToolResult(); + Path normalizedPath = normalizeLocalPath(filePath); + if (Files.exists(normalizedPath, LinkOption.NOFOLLOW_LINKS)) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed: file already exists: " + normalizedPath + ". Please use edit file tool to update."); + return result; + } + + try { + Path parent = normalizedPath.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.writeString(normalizedPath, content, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW); + cacheTheOriginalFileContent(ChangedFile.local(normalizedPath), StringUtils.EMPTY); + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile( + ChangedFile.local(normalizedPath), FileChangeType.Created); + result.addContent("File created at: " + normalizedPath); + result.setStatus(ToolInvocationStatus.success); + } catch (IOException e) { + CopilotCore.LOGGER.error("Error creating local file", e); + result.setStatus(ToolInvocationStatus.error); + result.addContent("Error creating file: " + e.getMessage()); + } + + return result; } /** @@ -152,33 +201,36 @@ private void createParentFolders(IResource parent) throws CoreException { } @Override - public void onKeepAllChanges(List files) { - files.forEach(this::onKeepChange); + public void onKeepChange(ChangedFile file) { + removeCachedFileContent(file); + closeCompareEditor(file); } @Override - public void onKeepChange(IFile file) { + public void onUndoChange(ChangedFile file) throws CoreException, IOException { + deleteCreatedFile(file); + removeCachedFileContent(file); closeCompareEditor(file); } - @Override - public void onUndoAllChanges(List files) throws CoreException { - for (IFile file : files) { - onUndoChange(file); + private void deleteCreatedFile(ChangedFile file) throws CoreException, IOException { + if (file.isWorkspaceFile()) { + IFile workspaceFile = file.getWorkspaceFile(); + if (workspaceFile != null && workspaceFile.exists()) { + workspaceFile.delete(true, new NullProgressMonitor()); + } + return; } + Files.deleteIfExists(file.getLocalPath()); } @Override - public void onUndoChange(IFile file) throws CoreException { - if (file != null && file.exists()) { - file.delete(true, new NullProgressMonitor()); + public void onViewDiff(ChangedFile file) { + if (file.isWorkspaceFile()) { + SwtUtils.invokeOnDisplayThreadAsync(() -> UiUtils.openInEditor(file.getWorkspaceFile())); + return; } - closeCompareEditor(file); - } - - @Override - public void onViewDiff(IFile file) { - SwtUtils.invokeOnDisplayThreadAsync(() -> UiUtils.openInEditor(file)); + SwtUtils.invokeOnDisplayThreadAsync(() -> UiUtils.openLocalFileInEditor(file.getLocalPath())); } @Override diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java index 9a6c532e..6a20fcd6 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java @@ -7,13 +7,14 @@ import java.io.IOException; import java.io.UnsupportedEncodingException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; import java.util.Arrays; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; -import org.eclipse.compare.CompareEditorInput; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IResource; import org.eclipse.core.runtime.CoreException; @@ -52,7 +53,7 @@ public LanguageModelToolInformation getToolInformation() { // Set the name and description of the tool toolInfo.setName(TOOL_NAME); toolInfo.setDescription(""" - Insert new code into an existing file in the workspace. + Insert new code into an existing workspace file or local filesystem file. Use this tool once per file that needs to be modified, even if there are multiple changes for a file. Generate the "explanation" property first. The system is very smart and can understand how to apply your edits to the files, @@ -122,30 +123,8 @@ class Person { public CompletableFuture invoke(Map input, ChatView chatView) { CompletableFuture resultFuture = new CompletableFuture<>(); if (input.get("filePath") instanceof String filePath) { - IFile file = FileUtils.getFileFromPath(filePath, true); - - if (file == null || !file.exists()) { - resultFuture.complete(new LanguageModelToolResult[] { - new LanguageModelToolResult("The file path provided does not exist. Please check the path and try again.", - ToolInvocationStatus.error) }); - return resultFuture; - } - if (input.get("code") instanceof String code) { - CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(file, FileChangeType.Changed); - cacheTheOriginalFileContent(file); - try { - applyChangesToFile(code, file); - } catch (CoreException | IOException e) { - CopilotCore.LOGGER.error("Error replacing file content", e); - resultFuture.complete(new LanguageModelToolResult[] { new LanguageModelToolResult( - "Failed to apply changes to the file: " + e.getMessage(), ToolInvocationStatus.error) }); - return resultFuture; - } - refreshCompareEditorIfOpen(fileContentCache.get(file), file); - // Must return the updated content as a result to the CLS. - resultFuture.complete( - new LanguageModelToolResult[] { new LanguageModelToolResult(code, ToolInvocationStatus.success) }); + resultFuture.complete(editFile(filePath, code)); } else { resultFuture.complete(new LanguageModelToolResult[] { new LanguageModelToolResult("The code provided is not a valid string. Please check the code and try again.", @@ -160,6 +139,60 @@ public CompletableFuture invoke(Map i return resultFuture; } + private LanguageModelToolResult[] editFile(String filePath, String code) { + IFile file = FileUtils.getFileFromPath(filePath, true); + + if (file != null && file.exists()) { + return editWorkspaceFile(file, code); + } + + Path localPath = getLocalFilePath(filePath); + if (localPath != null && Files.isRegularFile(localPath, LinkOption.NOFOLLOW_LINKS)) { + return editLocalFile(localPath, code); + } + + return new LanguageModelToolResult[] { + new LanguageModelToolResult("The file path provided does not exist. Please check the path and try again.", + ToolInvocationStatus.error) }; + } + + private LanguageModelToolResult[] editWorkspaceFile(IFile file, String code) { + ChangedFile changedFile = ChangedFile.workspace(file); + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(changedFile, + FileChangeType.Changed); + cacheTheOriginalFileContent(changedFile); + try { + applyChangesToFile(code, file); + } catch (CoreException | IOException e) { + CopilotCore.LOGGER.error("Error replacing file content", e); + return new LanguageModelToolResult[] { new LanguageModelToolResult( + "Failed to apply changes to the file: " + e.getMessage(), ToolInvocationStatus.error) }; + } + refreshCompareEditorIfOpen(getCachedFileContent(changedFile), changedFile); + return new LanguageModelToolResult[] { new LanguageModelToolResult(code, ToolInvocationStatus.success) }; + } + + private LanguageModelToolResult[] editLocalFile(Path filePath, String code) { + Path normalizedPath = normalizeLocalPath(filePath); + ChangedFile changedFile = ChangedFile.local(normalizedPath); + try { + String originalContent = getCachedFileContent(changedFile); + if (originalContent == null) { + originalContent = Files.readString(normalizedPath, StandardCharsets.UTF_8); + } + Files.writeString(normalizedPath, code, StandardCharsets.UTF_8); + cacheTheOriginalFileContent(changedFile, originalContent); + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(changedFile, + FileChangeType.Changed); + refreshCompareEditorIfOpen(getCachedFileContent(changedFile), changedFile); + return new LanguageModelToolResult[] { new LanguageModelToolResult(code, ToolInvocationStatus.success) }; + } catch (IOException e) { + CopilotCore.LOGGER.error("Error replacing local file content", e); + return new LanguageModelToolResult[] { new LanguageModelToolResult( + "Failed to apply changes to the file: " + e.getMessage(), ToolInvocationStatus.error) }; + } + } + private void applyChangesToFile(String changedContent, IFile file) throws CoreException, IOException { if (!validateEdit(file)) { throw new IllegalStateException("File validation failed for " + file.getFullPath()); @@ -189,55 +222,40 @@ private ByteArrayInputStream getInputStream(String changedContent, IFile file) { } @Override - public void onKeepChange(IFile file) { - fileContentCache.remove(file); + public void onKeepChange(ChangedFile file) { + removeCachedFileContent(file); closeCompareEditor(file); } @Override - public void onKeepAllChanges(List files) { - for (IFile file : files) { - onKeepChange(file); - } - } - - @Override - public void onUndoChange(IFile file) throws CoreException, IOException { + public void onUndoChange(ChangedFile file) throws CoreException, IOException { undoChangesToFile(file); closeCompareEditor(file); } @Override - public void onUndoAllChanges(List files) throws CoreException, IOException { - for (IFile file : files) { - onUndoChange(file); + public void onViewDiff(ChangedFile file) { + if (bringCompareEditorToTopIfOpen(file)) { + return; } + compareStringWithFile(getCachedFileContent(file), file); } - @Override - public void onViewDiff(IFile file) { - CompareEditorInput input = compareEditorInputMap.get(file); - if (input != null) { - if (isCompareEditorOpen(input)) { - bringCompareEditorToTop(input); - return; - } - // Compare editor was closed by the user, remove stale entry and recreate - compareEditorInputMap.remove(file); + private void undoChangesToFile(ChangedFile file) throws CoreException, IOException { + String fileCache = getCachedFileContent(file); + if (fileCache == null) { + return; + } + if (file.isWorkspaceFile()) { + applyChangesToFile(fileCache, file.getWorkspaceFile()); + } else { + Files.writeString(file.getLocalPath(), fileCache, StandardCharsets.UTF_8); } - compareStringWithFile(fileContentCache.get(file), file); + removeCachedFileContent(file); } @Override public void onResolveAllChanges() { cleanupChangedFiles(); } - - private void undoChangesToFile(IFile file) throws CoreException, IOException { - String fileCache = fileContentCache.get(file); - if (fileCache != null) { - applyChangesToFile(fileCache, file); - } - fileContentCache.remove(file); - } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java index 1bf6fc6e..3ad526bf 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java @@ -8,11 +8,17 @@ import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.lang.reflect.InvocationTargetException; +import java.net.URI; +import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; import org.eclipse.compare.CompareConfiguration; import org.eclipse.compare.CompareEditorInput; @@ -30,6 +36,7 @@ import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.IStatus; import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.core.runtime.Status; import org.eclipse.swt.graphics.Image; import org.eclipse.ui.IEditorPart; import org.eclipse.ui.IEditorReference; @@ -49,8 +56,8 @@ * Abstract class for handling file change tool related actions. */ public abstract class FileToolBase extends BaseTool { - protected static Map compareEditorInputMap = new ConcurrentHashMap<>(); - protected static Map fileContentCache = new ConcurrentHashMap<>(); + protected static Map compareEditorInputMap = new ConcurrentHashMap<>(); + protected static Map fileContentCache = new ConcurrentHashMap<>(); @Override public abstract CompletableFuture invoke(Map input, ChatView chatView); @@ -59,7 +66,7 @@ public abstract class FileToolBase extends BaseTool { * Common method to handle cleanup of file changes. */ protected void cleanupChangedFiles() { - for (IFile file : compareEditorInputMap.keySet()) { + for (ChangedFile file : compareEditorInputMap.keySet()) { closeCompareEditor(file); } compareEditorInputMap.clear(); @@ -67,24 +74,62 @@ protected void cleanupChangedFiles() { } /** - * Caches the original content of the file to be compared with the proposed changes. + * Caches the original content of the changed file to be compared with the proposed changes. * - * @param file The file whose original content is to be cached. + * @param file The changed file whose original content is to be cached. */ - protected void cacheTheOriginalFileContent(IFile file) { + protected void cacheTheOriginalFileContent(ChangedFile file) { if (fileContentCache.containsKey(file)) { // We only need to cache the original file content once to keep the initial file content so that we can undo the // entire file edit even the file has been modified for multiple rounds. return; } - try (InputStream inputStream = file.getContents()) { - String content = new String(inputStream.readAllBytes(), PlatformUtils.getFileCharset(file)); - fileContentCache.put(file, content); + try { + fileContentCache.put(file, readCurrentFileContent(file)); } catch (IOException | CoreException e) { CopilotCore.LOGGER.error("Error caching original file content", e); } } + /** + * Caches the original content for a changed file if no baseline exists yet. + * + * @param file The file whose original content is to be cached. + * @param content The content to use as the original baseline. + */ + protected void cacheTheOriginalFileContent(ChangedFile file, String content) { + fileContentCache.putIfAbsent(file, content); + } + + private String readCurrentFileContent(ChangedFile file) throws IOException, CoreException { + if (file.isWorkspaceFile()) { + IFile workspaceFile = file.getWorkspaceFile(); + try (InputStream inputStream = workspaceFile.getContents()) { + return new String(inputStream.readAllBytes(), PlatformUtils.getFileCharset(workspaceFile)); + } + } + return Files.readString(file.getLocalPath(), StandardCharsets.UTF_8); + } + + /** + * Gets the cached original content for a changed file. + * + * @param file The changed file whose cached content should be returned. + * @return the cached content, or null if no content is cached. + */ + protected String getCachedFileContent(ChangedFile file) { + return fileContentCache.get(file); + } + + /** + * Removes the cached original content for a changed file. + * + * @param file The changed file whose cached content should be removed. + */ + protected void removeCachedFileContent(ChangedFile file) { + fileContentCache.remove(file); + } + /** * Validate the edit to ensure the files are writable. * @@ -105,14 +150,12 @@ public void run(IProgressMonitor monitor) throws CoreException { } /** - * Compares the given string with the content of the given file in a compare editor. + * Compares the given string with the content of a changed file in a compare editor. * * @param originalFileContent The original string content of the file to compare with. - * @param file The user's file with the proposed changes has been applied. - * @throws InvocationTargetException If the operation is canceled. - * @throws InterruptedException If the operation is canceled. + * @param file The changed file with the proposed changes applied. */ - protected void compareStringWithFile(String originalFileContent, IFile file) { + protected void compareStringWithFile(String originalFileContent, ChangedFile file) { try { CompareEditorInput input = createCompareEditorInput(originalFileContent, file); input.run(new NullProgressMonitor()); @@ -130,47 +173,12 @@ protected void compareStringWithFile(String originalFileContent, IFile file) { } /** - * Updates the current or creates a new compare editor with the given file content and file. - * - * @param originalFileContent The original string content of the file to compare with. - * @param file The user's file with the proposed changes has been applied. - */ - protected void updateOrCreateCompareStringWithFile(String fileContent, IFile file) { - if (fileContent == null) { - return; - } - - CompareEditorInput input = compareEditorInputMap.get(file); - if (input != null) { - if (fileContent.equals(fileContentCache.get(file))) { - SwtUtils.invokeOnDisplayThreadAsync(() -> { - CompareUI.reuseCompareEditor(input, (IReusableEditor) getCompareEditor(input)); - }); - } else { - CompareEditorInput newInput = createCompareEditorInput(fileContent, file); - compareEditorInputMap.put(file, newInput); - SwtUtils.invokeOnDisplayThreadAsync(() -> { - CompareEditorInput compareEditorInput = compareEditorInputMap.get(file); - if (compareEditorInput != null) { - CompareUI.reuseCompareEditor(compareEditorInput, (IReusableEditor) getCompareEditor(compareEditorInput)); - } - }); - } - bringCompareEditorToTop(input); - } else { - // If not, create a new compare editor - compareStringWithFile(fileContent, file); - } - } - - /** - * Refreshes the compare editor for the given file only if it is already open. Does not open a new editor or steal - * focus. + * Refreshes the compare editor for the given changed file only if it is already open. * * @param fileContent The original file content to compare against. - * @param file The file whose compare editor should be refreshed. + * @param file The changed file whose compare editor should be refreshed. */ - protected void refreshCompareEditorIfOpen(String fileContent, IFile file) { + protected void refreshCompareEditorIfOpen(String fileContent, ChangedFile file) { if (fileContent == null) { return; } @@ -184,11 +192,10 @@ protected void refreshCompareEditorIfOpen(String fileContent, IFile file) { // If the compare editor is closed, remove the input from the map and skip refreshing. compareEditorInputMap.remove(file); return; - } else { - CompareEditorInput compareEditorInput = compareEditorInputMap.get(file); - if (compareEditorInput != null) { - CompareUI.reuseCompareEditor(compareEditorInput, (IReusableEditor) editor); - } + } + CompareEditorInput compareEditorInput = compareEditorInputMap.get(file); + if (compareEditorInput != null) { + CompareUI.reuseCompareEditor(compareEditorInput, (IReusableEditor) editor); } }); } @@ -236,12 +243,11 @@ private IEditorPart getCompareEditor(CompareEditorInput input) { } /** - * Close the compare editor for the given file if it is open. + * Closes the compare editor for the given changed file if it is open. * - * @param file The file to check. - * @return true if the compare editor is open, false otherwise. + * @param file The changed file to check. */ - protected void closeCompareEditor(IFile file) { + protected void closeCompareEditor(ChangedFile file) { CompareEditorInput input = compareEditorInputMap.get(file); if (input != null) { SwtUtils.invokeOnDisplayThread(() -> { @@ -262,70 +268,154 @@ protected void closeCompareEditor(IFile file) { compareEditorInputMap.remove(file); } - private CompareEditorInput createCompareEditorInput(String comparedContent, IFile file) { - // Create a new CompareConfiguration - CompareConfiguration config = new CompareConfiguration(); - config.setLeftLabel(Messages.agent_tool_compareEditor_proposedChangesTitle.replaceAll("\"", "")); - config.setRightLabel(file.getName()); + /** + * Brings the compare editor for a changed file to the top if it is open. + * + * @param file The changed file whose compare editor should be shown. + * @return true if an open compare editor was found, false otherwise. + */ + protected boolean bringCompareEditorToTopIfOpen(ChangedFile file) { + CompareEditorInput input = compareEditorInputMap.get(file); + if (input == null) { + return false; + } + if (isCompareEditorOpen(input)) { + bringCompareEditorToTop(input); + return true; + } + compareEditorInputMap.remove(file); + return false; + } - // Enable editing on the proposed changes side and disable it on the original file side. Eclipse's original side - // and - // changes side are swapped, so we need to set the left side as editable to edit the proposed changes. - config.setLeftEditable(true); - config.setRightEditable(false); + /** + * Normalizes a local path for cache and map lookups. + * + * @param file the local file path + * @return the normalized absolute path + */ + protected Path normalizeLocalPath(Path file) { + return file.toAbsolutePath().normalize(); + } - // Set up the configuration to properly show differences - config.setProperty(CompareConfiguration.USE_OUTLINE_VIEW, Boolean.TRUE); - config.setProperty(CompareConfiguration.SHOW_PSEUDO_CONFLICTS, Boolean.TRUE); - config.setProperty(CompareConfiguration.IGNORE_WHITESPACE, Boolean.FALSE); + /** + * Resolves an absolute local filesystem path from a path or file URI. + * + * @param filePath the path or URI to resolve + * @return the local filesystem path, or null if the input is not an absolute local path + */ + protected Path getLocalFilePath(String filePath) { + try { + if (filePath.startsWith("file:")) { + return Paths.get(new URI(filePath)); + } + Path path = Paths.get(filePath); + return path.isAbsolute() ? path : null; + } catch (IllegalArgumentException | URISyntaxException e) { + CopilotCore.LOGGER.error("Invalid local file path: " + filePath, e); + return null; + } + } + + private CompareEditorInput createWorkspaceCompareEditorInput(String comparedContent, IFile file) { + ChangedFile changedFile = ChangedFile.workspace(file); + EditableFileCompareInput originalFile = new EditableFileCompareInput(file); + return createCompareEditorInputForTarget(comparedContent, originalFile.getName(), originalFile.getType(), + PlatformUtils.getFileCharset(file), () -> originalFile, (diffNode, monitor) -> { + EditableFileCompareInput inputToBeApplied = (EditableFileCompareInput) diffNode.getLeft(); + try (InputStream inputStream = inputToBeApplied.getContents()) { + file.setContents(inputStream, true, true, monitor); + } catch (IOException e) { + CopilotCore.LOGGER.error("Error saving compare editor changes to file", e); + } + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().completeFile(changedFile); + removeCachedFileContent(changedFile); + }); + } + + private CompareEditorInput createLocalCompareEditorInput(String comparedContent, Path file) { + Path normalizedPath = normalizeLocalPath(file); + ChangedFile changedFile = ChangedFile.local(normalizedPath); + EditableFileCompareInput originalFile = new EditableFileCompareInput(normalizedPath); + return createCompareEditorInputForTarget(comparedContent, originalFile.getName(), originalFile.getType(), + StandardCharsets.UTF_8.name(), () -> originalFile, + (diffNode, monitor) -> { + EditableFileCompareInput inputToBeApplied = (EditableFileCompareInput) diffNode.getLeft(); + try (InputStream inputStream = inputToBeApplied.getContents()) { + Files.write(normalizedPath, inputStream.readAllBytes()); + } catch (IOException e) { + CopilotCore.LOGGER.error("Error saving compare editor changes to local file", e); + } + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().completeFile(changedFile); + removeCachedFileContent(changedFile); + }); + } + + private CompareEditorInput createCompareEditorInput(String comparedContent, ChangedFile file) { + if (file.isWorkspaceFile()) { + return createWorkspaceCompareEditorInput(comparedContent, file.getWorkspaceFile()); + } + return createLocalCompareEditorInput(comparedContent, file.getLocalPath()); + } + + private CompareEditorInput createCompareEditorInputForTarget(String comparedContent, String fileName, + String fileExtension, String charset, Supplier originalFileSupplier, + CompareContentSaver contentSaver) { + CompareConfiguration config = createCompareConfiguration(fileName); return new CompareEditorInput(config) { @Override protected Object prepareInput(IProgressMonitor monitor) throws InvocationTargetException, InterruptedException { monitor.beginTask("Calculating differences", 10); - setTitle(Messages.agent_tool_compareEditor_titlePrefix + file.getName()); - // Keep proposedChanges virtual file's name and type same as the originalFile original file's name and type - EditableStringCompareInput proposedChanges = new EditableStringCompareInput(comparedContent, file.getName(), - file.getFileExtension(), PlatformUtils.getFileCharset(file)); - EditableFileCompareInput originalFile = new EditableFileCompareInput(file); - - // Create a diff node with proper configuration for text comparison - DiffNode diffNode = new DiffNode(null, Differencer.CHANGE, null, originalFile, proposedChanges); - + setTitle(Messages.agent_tool_compareEditor_titlePrefix + fileName); + EditableStringCompareInput proposedChanges = new EditableStringCompareInput(comparedContent, fileName, + fileExtension, charset); + DiffNode diffNode = new DiffNode(null, Differencer.CHANGE, null, originalFileSupplier.get(), proposedChanges); monitor.done(); return diffNode; } @Override public void saveChanges(IProgressMonitor monitor) throws CoreException { - // We need to set the right side as editable to save the changes made to the proposed changes. Otherwise, the - // changes won't be saved. if (isDirty()) { config.setRightEditable(true); super.saveChanges(monitor); - // Get the diff node which contains the comparison inputs DiffNode diffNode = (DiffNode) getCompareResult(); if (diffNode != null) { - // Get the right side input (the original file with any edits made) - EditableFileCompareInput inputToBeApplied = (EditableFileCompareInput) diffNode.getLeft(); - - // Save the modified content back to the file - try (InputStream inputStream = inputToBeApplied.getContents()) { - file.setContents(inputStream, true, true, monitor); - } catch (IOException e) { - CopilotCore.LOGGER.error("Error saving compare editor changes to file", e); - } + contentSaver.save(diffNode, monitor); } - - // If user keeps the changes with keyboard shortcut, we also need to complete the file. - CopilotUi.getPlugin().getChatServiceManager().getFileToolService().completeFile(file); - fileContentCache.remove(file); } } }; } + private CompareConfiguration createCompareConfiguration(String rightLabel) { + CompareConfiguration config = new CompareConfiguration(); + config.setLeftLabel(Messages.agent_tool_compareEditor_proposedChangesTitle.replaceAll("\"", "")); + config.setRightLabel(rightLabel); + config.setLeftEditable(true); + config.setRightEditable(false); + config.setProperty(CompareConfiguration.USE_OUTLINE_VIEW, Boolean.TRUE); + config.setProperty(CompareConfiguration.SHOW_PSEUDO_CONFLICTS, Boolean.TRUE); + config.setProperty(CompareConfiguration.IGNORE_WHITESPACE, Boolean.FALSE); + return config; + } + + /** + * Saves the editable compare content back to the target file type. + */ + @FunctionalInterface + private interface CompareContentSaver { + /** + * Saves the edited content represented by a compare diff node. + * + * @param diffNode The diff node containing the editable compare inputs. + * @param monitor The progress monitor for the save operation. + * @throws CoreException if saving through Eclipse APIs fails. + */ + void save(DiffNode diffNode, IProgressMonitor monitor) throws CoreException; + } + /** * Dispose the file change summary bar and related resources. */ @@ -342,8 +432,10 @@ protected void dispose() { /** * Editable file compare input class to handle file content editing on the compare editor. */ - public class EditableFileCompareInput implements ITypedElement, IEncodedStreamContentAccessor, IEditableContent { - private IFile file; + public static final class EditableFileCompareInput implements ITypedElement, IEncodedStreamContentAccessor, + IEditableContent { + private final IFile workspaceFile; + private final Path localFile; private byte[] modifiedContent = null; /** @@ -352,12 +444,27 @@ public class EditableFileCompareInput implements ITypedElement, IEncodedStreamCo * @param file The file to be edited. */ public EditableFileCompareInput(IFile file) { - this.file = file; + this.workspaceFile = file; + this.localFile = null; + } + + /** + * Constructor for EditableFileCompareInput. + * + * @param file The local file to be edited. + */ + EditableFileCompareInput(Path file) { + this.workspaceFile = null; + this.localFile = file.toAbsolutePath().normalize(); } @Override public String getName() { - return file.getName(); + if (workspaceFile != null) { + return workspaceFile.getName(); + } + Path fileName = localFile.getFileName(); + return fileName == null ? localFile.toString() : fileName.toString(); } @Override @@ -367,11 +474,19 @@ public Image getImage() { @Override public String getType() { - return file.getFileExtension(); + if (workspaceFile != null) { + return workspaceFile.getFileExtension(); + } + return getLocalFileExtension(localFile); } + /** + * Gets the workspace file represented by this compare input. + * + * @return the workspace file + */ public IFile getFile() { - return file; + return workspaceFile; } @Override @@ -379,12 +494,19 @@ public InputStream getContents() throws CoreException { if (modifiedContent != null) { return new ByteArrayInputStream(modifiedContent); } - return file.getContents(); + if (workspaceFile != null) { + return workspaceFile.getContents(); + } + try { + return Files.newInputStream(localFile); + } catch (IOException e) { + throw new CoreException(Status.error("Error reading local file", e)); + } } @Override public String getCharset() throws CoreException { - return file.getCharset(); + return workspaceFile == null ? StandardCharsets.UTF_8.name() : workspaceFile.getCharset(); } @Override @@ -401,7 +523,6 @@ public void setContent(byte[] newContent) { public ITypedElement replace(ITypedElement dest, ITypedElement src) { if (src instanceof IStreamContentAccessor sca) { try (InputStream is = sca.getContents()) { - // Just store changes in memory modifiedContent = is.readAllBytes(); } catch (IOException | CoreException e) { CopilotCore.LOGGER.error("Error occurred while replacing file content", e); @@ -409,6 +530,15 @@ public ITypedElement replace(ITypedElement dest, ITypedElement src) { } return this; } + + private static String getLocalFileExtension(Path file) { + String name = file.getFileName() == null ? file.toString() : file.getFileName().toString(); + int index = name.lastIndexOf('.'); + if (index < 0 || index == name.length() - 1) { + return ""; + } + return name.substring(index + 1); + } } /** diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolService.java index 1e57daa9..151cf791 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolService.java @@ -6,13 +6,11 @@ import java.io.IOException; import java.util.ArrayList; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import org.eclipse.core.databinding.observable.sideeffect.ISideEffect; import org.eclipse.core.databinding.observable.value.IObservableValue; import org.eclipse.core.databinding.observable.value.WritableValue; -import org.eclipse.core.resources.IFile; import org.eclipse.core.runtime.CoreException; import org.eclipse.e4.core.services.events.IEventBroker; import org.eclipse.lsp4j.FileChangeType; @@ -35,7 +33,7 @@ * the files to be created or edited and the enable state of the button. */ public class FileToolService extends ChatBaseService { - private IObservableValue> filesObservable; + private IObservableValue> filesObservable; private IObservableValue buttonEnableObservable; private WorkingSetBar workingSetBar; @@ -78,7 +76,7 @@ public void bindWorkingSetBar(ChatView chatView) { ensureRealm(() -> { unbindWorkingSetBar(); filesSideEffect = ISideEffect.create(() -> filesObservable.getValue(), - (Map filesMap) -> { + (Map filesMap) -> { if (filesMap.isEmpty()) { disposeWorkingSetBar(); } else { @@ -154,7 +152,7 @@ public void setWorkingSetBarButtonStatus(boolean status) { /** * Set the changed files for the working set bar. */ - public void setChangedFiles(Map files) { + public void setChangedFiles(Map files) { ensureRealm(() -> { filesObservable.setValue(files); }); @@ -163,7 +161,7 @@ public void setChangedFiles(Map files) { /** * Get the changed files for the working set bar. */ - public Map getChangedFiles() { + public Map getChangedFiles() { return filesObservable.getValue(); } @@ -175,11 +173,11 @@ public WorkingSetBar getWorkingSetBar() { } /** - * Add a newly created file to the working set bar. + * Add a changed file to the working set bar. */ - public void addChangedFile(IFile file, FileChangeType fileChangeType) { + public void addChangedFile(ChangedFile file, FileChangeType fileChangeType) { ensureRealm(() -> { - Map filesMap = new LinkedHashMap<>(filesObservable.getValue()); + Map filesMap = new LinkedHashMap<>(filesObservable.getValue()); if (filesMap.containsKey(file)) { return; } @@ -194,9 +192,9 @@ public void addChangedFile(IFile file, FileChangeType fileChangeType) { * * @param file the file to complete */ - public void completeFile(IFile file) { + public void completeFile(ChangedFile file) { ensureRealm(() -> { - Map filesMap = new LinkedHashMap<>(filesObservable.getValue()); + Map filesMap = new LinkedHashMap<>(filesObservable.getValue()); filesMap.remove(file); filesObservable.setValue(filesMap); @@ -212,7 +210,7 @@ public void completeFile(IFile file) { * @param file the file to get the change type for * @return the file change type, or null if the file is not in the list */ - public FileChangeType getFileChangeTypeOf(IFile file) { + private FileChangeType getFileChangeTypeInternal(ChangedFile file) { FileChangeProperty property = filesObservable.getValue().get(file); if (property != null) { return property.getChangeType(); @@ -226,10 +224,10 @@ public FileChangeType getFileChangeTypeOf(IFile file) { * * @param file the file to keep changes for */ - public void onKeepChange(IFile file) { - if (getFileChangeTypeOf(file) == FileChangeType.Created) { + public void onKeepChange(ChangedFile file) { + if (getFileChangeTypeInternal(file) == FileChangeType.Created) { this.createFileTool.onKeepChange(file); - } else if (getFileChangeTypeOf(file) == FileChangeType.Changed) { + } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { this.editFileTool.onKeepChange(file); } this.completeFile(file); @@ -239,8 +237,13 @@ public void onKeepChange(IFile file) { * Handles the action of keeping all changes to files. */ public void onKeepAllChanges() { - this.createFileTool.onKeepAllChanges(getCreatedFiles()); - this.editFileTool.onKeepAllChanges(getEditedFiles()); + for (ChangedFile file : new ArrayList<>(filesObservable.getValue().keySet())) { + if (getFileChangeTypeInternal(file) == FileChangeType.Created) { + this.createFileTool.onKeepChange(file); + } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { + this.editFileTool.onKeepChange(file); + } + } onResolveAllChanges(); } @@ -249,11 +252,11 @@ public void onKeepAllChanges() { * * @param file the file to undo changes for */ - public void onUndoChange(IFile file) { + public void onUndoChange(ChangedFile file) { try { - if (getFileChangeTypeOf(file) == FileChangeType.Created) { + if (getFileChangeTypeInternal(file) == FileChangeType.Created) { this.createFileTool.onUndoChange(file); - } else if (getFileChangeTypeOf(file) == FileChangeType.Changed) { + } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { this.editFileTool.onUndoChange(file); } } catch (CoreException | IOException e) { @@ -267,8 +270,13 @@ public void onUndoChange(IFile file) { */ public void onUndoAllChanges() { try { - this.createFileTool.onUndoAllChanges(getCreatedFiles()); - this.editFileTool.onUndoAllChanges(getEditedFiles()); + for (ChangedFile file : new ArrayList<>(filesObservable.getValue().keySet())) { + if (getFileChangeTypeInternal(file) == FileChangeType.Created) { + this.createFileTool.onUndoChange(file); + } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { + this.editFileTool.onUndoChange(file); + } + } } catch (CoreException | IOException e) { CopilotCore.LOGGER.error("Error undoing all changes for the files", e); } @@ -280,10 +288,14 @@ public void onUndoAllChanges() { * * @param file the file to view the diff for */ - public void onViewDiff(IFile file) { - if (getFileChangeTypeOf(file) == FileChangeType.Created) { + public void onViewDiff(ChangedFile file) { + FileChangeProperty property = filesObservable.getValue().get(file); + if (property == null) { + return; + } + if (property.getChangeType() == FileChangeType.Created) { this.createFileTool.onViewDiff(file); - } else if (getFileChangeTypeOf(file) == FileChangeType.Changed) { + } else if (property.getChangeType() == FileChangeType.Changed) { this.editFileTool.onViewDiff(file); } } @@ -313,29 +325,8 @@ public void disposeWorkingSetBar() { } } - private List getCreatedFiles() { - List createdFiles = new ArrayList<>(); - for (Map.Entry entry : this.filesObservable.getValue().entrySet()) { - if (entry.getValue().getChangeType() == FileChangeType.Created) { - createdFiles.add(entry.getKey()); - } - } - return createdFiles; - } - - private List getEditedFiles() { - List editedFiles = new ArrayList<>(); - for (Map.Entry entry : this.filesObservable.getValue().entrySet()) { - if (entry.getValue().getChangeType() == FileChangeType.Changed) { - editedFiles.add(entry.getKey()); - } - } - return editedFiles; - } - /** - * Class for file change properties. changeType - The type of file change (new or edited). isCompleted - Whether the - * file change is completed or not. + * Class for file change properties. */ public static class FileChangeProperty { private FileChangeType changeType; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/WorkingSetHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/WorkingSetHandler.java index c7619f7f..7176e9e2 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/WorkingSetHandler.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/WorkingSetHandler.java @@ -4,9 +4,7 @@ package com.microsoft.copilot.eclipse.ui.chat.tools; import java.io.IOException; -import java.util.List; -import org.eclipse.core.resources.IFile; import org.eclipse.core.runtime.CoreException; /** @@ -18,12 +16,7 @@ public interface WorkingSetHandler { * * @param file the file to keep changes for */ - void onKeepChange(IFile file) throws IOException, CoreException; - - /** - * Handles the action of keeping all changes to files. - */ - void onKeepAllChanges(List files) throws IOException, CoreException; + void onKeepChange(ChangedFile file) throws IOException, CoreException; /** * Handles the action of undoing changes to a file. @@ -33,22 +26,14 @@ public interface WorkingSetHandler { * @throws CoreException if an error occurs during the undo operation, such as a failure to delete a file * @throws IOException if an error occurs while writing to the file */ - void onUndoChange(IFile file) throws CoreException, IOException; - - /** - * Handles the action of undoing all changes to files. - * - * @throws CoreException if error occurs during the undo all operation, such as a failure to delete a file - * @throws IOException if an error occurs while writing to the file - */ - void onUndoAllChanges(List files) throws CoreException, IOException; + void onUndoChange(ChangedFile file) throws CoreException, IOException; /** * Handles the action of viewing the diff of a file. * * @param file the file to view the diff for */ - void onViewDiff(IFile file); + void onViewDiff(ChangedFile file); /** * Handles the action of click done button to resolve all changes. diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java index 7370d76f..5d1ab2a9 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java @@ -8,6 +8,8 @@ import java.io.InputStream; import java.net.URI; import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; @@ -29,6 +31,8 @@ import org.eclipse.core.commands.NotHandledException; import org.eclipse.core.commands.ParameterizedCommand; import org.eclipse.core.commands.common.NotDefinedException; +import org.eclipse.core.filesystem.EFS; +import org.eclipse.core.filesystem.IFileStore; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IResource; import org.eclipse.core.runtime.preferences.InstanceScope; @@ -221,6 +225,27 @@ public static IEditorPart openInEditor(IFile file) { return null; } + /** + * Opens the given local filesystem file in an editor. + */ + public static IEditorPart openLocalFileInEditor(Path file) { + if (file == null || !Files.exists(file)) { + CopilotCore.LOGGER.error(new IllegalArgumentException("Cannot open editor: local file is null or doesn't exist")); + return null; + } + + try { + IWorkbenchPage page = getActivePage(); + if (page != null) { + IFileStore fileStore = EFS.getLocalFileSystem().getStore(file.toUri()); + return IDE.openEditorOnFileStore(page, fileStore); + } + } catch (PartInitException e) { + CopilotCore.LOGGER.error(e); + } + return null; + } + /** * Opens the file in the editor. */