From 084185778e287b836c867bae28f85ec794b8a284 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Thu, 21 May 2026 15:28:04 +0800 Subject: [PATCH] support edit/create local files --- .../local-file-edit-and-create-tools.md | 194 ++++++++++++++++ .../eclipse/ui/chat/WorkingSetBarTest.java | 41 ++-- .../ui/chat/tools/CreateFileToolTest.java | 58 ++++- .../ui/chat/tools/EditFileToolTest.java | 178 +++++++++++++++ .../eclipse/ui/chat/WorkingSetBar.java | 19 +- .../eclipse/ui/chat/tools/ChangedFile.java | 123 +++++++++++ .../eclipse/ui/chat/tools/CreateFileTool.java | 148 ++++++++++--- .../eclipse/ui/chat/tools/EditFileTool.java | 146 +++++++++--- .../tools/EditableLocalFileCompareInput.java | 135 ++++++++++++ .../eclipse/ui/chat/tools/FileToolBase.java | 207 ++++++++++++++++++ .../ui/chat/tools/FileToolService.java | 176 ++++++++++----- .../ui/chat/tools/WorkingSetHandler.java | 23 +- 12 files changed, 1300 insertions(+), 148 deletions(-) create mode 100644 com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md create mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileToolTest.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ChangedFile.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditableLocalFileCompareInput.java 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..d9ed886e --- /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 shows an empty-baseline diff + +**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 the Compare editor shows an empty original side and `created local content` on the modified side. + +#### 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 diff baseline for the created file is empty. +- No error dialog is shown and the Eclipse error log has no local file create or Compare UI 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 file diff** -- The Compare editor shows empty original content vs. 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..b8f09969 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, false); 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, false); 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, false); 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, false); 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, false); 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, false); 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, false); 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, false); 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, false); 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, false); 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, false); 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, false); 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, false); 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, false); 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, false); 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, false); 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, boolean isHandled) { + 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..6e6e8609 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 @@ -7,8 +7,11 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; 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 +22,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 +57,9 @@ class CreateFileToolTest { @Mock private FileToolService mockFileToolService; + @TempDir + private Path tempDir; + private MockedStatic mockedCopilotUi; @BeforeEach @@ -251,11 +259,57 @@ 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 testOnUndoChangeWithExternalLocalFileDeletesFile() throws Exception { + Path newFile = tempDir.resolve("external-file-to-undo.txt"); + Files.writeString(newFile, "test content"); + + createFileTool.onUndoChange(newFile); + + assertTrue(Files.notExists(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 +317,7 @@ void testInvokeWithInvalidPathReturnsErrorStatus() throws InterruptedException, LanguageModelToolResult[] results = future.get(); // Assert - assertErrorResult(results, "Error creating file"); + assertErrorResult(results, "does not exist in the workspace"); } @Test 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..51b8c8f3 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileToolTest.java @@ -0,0 +1,178 @@ +// 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(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.getLocalFileContentCache(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.getLocalFileContentCache(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 { + Map input = new HashMap<>(); + input.put("filePath", filePath); + input.put("code", code); + input.put("explanation", "test edit"); + + 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() { + compareEditorInputMap.clear(); + fileContentCache.clear(); + localCompareEditorInputMap.clear(); + localFileContentCache.clear(); + } + + private static String getLocalFileContentCache(Path file) { + return localFileContentCache.get(file.toAbsolutePath().normalize()); + } + } +} \ 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..f0373b4a 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; @@ -304,7 +304,7 @@ class ChangedFiles extends Composite { private final ScrolledComposite scrolledComposite; 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 @@ -348,12 +348,13 @@ public ChangedFiles(Composite parent, int style, Map // 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..991ef612 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,13 +5,21 @@ import java.io.ByteArrayInputStream; import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.Paths; +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; import org.apache.commons.lang3.StringUtils; +import org.eclipse.compare.CompareEditorInput; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IFolder; import org.eclipse.core.resources.IResource; @@ -19,6 +27,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; @@ -90,34 +99,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); + } + + 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(); - // Check if file already exists + 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(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 +154,49 @@ 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(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; + } + + private 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; + } } /** @@ -151,21 +217,19 @@ private void createParentFolders(IResource parent) throws CoreException { } } - @Override - public void onKeepAllChanges(List files) { - files.forEach(this::onKeepChange); - } - @Override public void onKeepChange(IFile file) { closeCompareEditor(file); } + /** + * Handles the action of keeping changes to a local file. + * + * @param file the local file to keep changes for + */ @Override - public void onUndoAllChanges(List files) throws CoreException { - for (IFile file : files) { - onUndoChange(file); - } + public void onKeepChange(Path file) { + closeCompareEditor(file); } @Override @@ -176,11 +240,43 @@ public void onUndoChange(IFile file) throws CoreException { closeCompareEditor(file); } + /** + * Handles the action of undoing creation of a local file. + * + * @param file the local file to delete + * @throws IOException if an error occurs while deleting the file + */ + @Override + public void onUndoChange(Path file) throws IOException { + Path normalizedPath = normalizeLocalPath(file); + Files.deleteIfExists(normalizedPath); + closeCompareEditor(normalizedPath); + } + @Override public void onViewDiff(IFile file) { SwtUtils.invokeOnDisplayThreadAsync(() -> UiUtils.openInEditor(file)); } + /** + * Handles the action of viewing the diff of a created local file. + * + * @param file the local file to view + */ + @Override + public void onViewDiff(Path file) { + Path normalizedPath = normalizeLocalPath(file); + CompareEditorInput input = localCompareEditorInputMap.get(normalizedPath); + if (input != null) { + if (isCompareEditorOpen(input)) { + bringCompareEditorToTop(input); + return; + } + localCompareEditorInputMap.remove(normalizedPath); + } + compareStringWithFile("", normalizedPath); + } + @Override public void onResolveAllChanges() { cleanupChangedFiles(); 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..8263efdb 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 @@ -6,10 +6,15 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Arrays; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -122,30 +127,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 +143,67 @@ 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) { + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(ChangedFile.workspace(file), + FileChangeType.Changed); + cacheTheOriginalFileContent(file); + 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(fileContentCache.get(file), file); + return new LanguageModelToolResult[] { new LanguageModelToolResult(code, ToolInvocationStatus.success) }; + } + + private LanguageModelToolResult[] editLocalFile(Path filePath, String code) { + Path normalizedPath = normalizeLocalPath(filePath); + try { + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile( + ChangedFile.local(normalizedPath), FileChangeType.Changed); + cacheTheOriginalFileContent(normalizedPath); + Files.writeString(normalizedPath, code, StandardCharsets.UTF_8); + refreshCompareEditorIfOpen(localFileContentCache.get(normalizedPath), normalizedPath); + 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 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 void applyChangesToFile(String changedContent, IFile file) throws CoreException, IOException { if (!validateEdit(file)) { throw new IllegalStateException("File validation failed for " + file.getFullPath()); @@ -194,11 +238,16 @@ public void onKeepChange(IFile file) { closeCompareEditor(file); } + /** + * Handles the action of keeping changes to a local file. + * + * @param file the local file to keep changes for + */ @Override - public void onKeepAllChanges(List files) { - for (IFile file : files) { - onKeepChange(file); - } + public void onKeepChange(Path file) { + Path normalizedPath = normalizeLocalPath(file); + localFileContentCache.remove(normalizedPath); + closeCompareEditor(normalizedPath); } @Override @@ -207,11 +256,16 @@ public void onUndoChange(IFile file) throws CoreException, IOException { closeCompareEditor(file); } + /** + * Handles the action of undoing changes to a local file. + * + * @param file the local file to undo changes for + * @throws IOException if an error occurs while writing to the file + */ @Override - public void onUndoAllChanges(List files) throws CoreException, IOException { - for (IFile file : files) { - onUndoChange(file); - } + public void onUndoChange(Path file) throws IOException { + undoChangesToFile(file); + closeCompareEditor(file); } @Override @@ -228,6 +282,25 @@ public void onViewDiff(IFile file) { compareStringWithFile(fileContentCache.get(file), file); } + /** + * Handles the action of viewing the diff of a local file. + * + * @param file the local file to view the diff for + */ + @Override + public void onViewDiff(Path file) { + Path normalizedPath = normalizeLocalPath(file); + CompareEditorInput input = localCompareEditorInputMap.get(normalizedPath); + if (input != null) { + if (isCompareEditorOpen(input)) { + bringCompareEditorToTop(input); + return; + } + localCompareEditorInputMap.remove(normalizedPath); + } + compareStringWithFile(localFileContentCache.get(normalizedPath), normalizedPath); + } + @Override public void onResolveAllChanges() { cleanupChangedFiles(); @@ -240,4 +313,13 @@ private void undoChangesToFile(IFile file) throws CoreException, IOException { } fileContentCache.remove(file); } + + private void undoChangesToFile(Path file) throws IOException { + Path normalizedPath = normalizeLocalPath(file); + String fileCache = localFileContentCache.get(normalizedPath); + if (fileCache != null) { + Files.writeString(normalizedPath, fileCache, StandardCharsets.UTF_8); + } + localFileContentCache.remove(normalizedPath); + } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditableLocalFileCompareInput.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditableLocalFileCompareInput.java new file mode 100644 index 00000000..79100b50 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditableLocalFileCompareInput.java @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.eclipse.compare.IEditableContent; +import org.eclipse.compare.IEncodedStreamContentAccessor; +import org.eclipse.compare.IStreamContentAccessor; +import org.eclipse.compare.ITypedElement; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.Status; +import org.eclipse.swt.graphics.Image; + +import com.microsoft.copilot.eclipse.core.CopilotCore; + +/** + * Editable local file compare input class to handle file content editing on the compare editor. + */ +final class EditableLocalFileCompareInput implements ITypedElement, IEncodedStreamContentAccessor, IEditableContent { + private final Path file; + private byte[] modifiedContent = null; + + /** + * Constructor for EditableLocalFileCompareInput. + * + * @param file The local file to be edited. + */ + EditableLocalFileCompareInput(Path file) { + this.file = normalizeLocalPath(file); + } + + @Override + public String getName() { + Path fileName = file.getFileName(); + return fileName == null ? file.toString() : fileName.toString(); + } + + @Override + public Image getImage() { + return null; + } + + @Override + public String getType() { + return getLocalFileExtension(file); + } + + @Override + public InputStream getContents() throws CoreException { + if (modifiedContent != null) { + return new ByteArrayInputStream(modifiedContent); + } + try { + return Files.newInputStream(file); + } catch (IOException e) { + throw new CoreException(Status.error("Error reading local file", e)); + } + } + + @Override + public String getCharset() throws CoreException { + return StandardCharsets.UTF_8.name(); + } + + @Override + public boolean isEditable() { + return true; + } + + @Override + public void setContent(byte[] newContent) { + this.modifiedContent = newContent; + } + + @Override + public ITypedElement replace(ITypedElement dest, ITypedElement src) { + if (src instanceof IStreamContentAccessor sca) { + try (InputStream is = sca.getContents()) { + modifiedContent = is.readAllBytes(); + } catch (IOException | CoreException e) { + CopilotCore.LOGGER.error("Error occurred while replacing local file content", e); + } + } + return this; + } + + private static Path normalizeLocalPath(Path file) { + return file.toAbsolutePath().normalize(); + } + + 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); + } + + @Override + public int hashCode() { + int result = Objects.hash(file); + result = 31 * result + Arrays.hashCode(modifiedContent); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof EditableLocalFileCompareInput other)) { + return false; + } + return Objects.equals(file, other.file) && Arrays.equals(modifiedContent, other.modifiedContent); + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("file", file); + builder.append("modifiedContent", modifiedContent); + 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/FileToolBase.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java index 1bf6fc6e..beeaac32 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 @@ -9,6 +9,8 @@ import java.io.UnsupportedEncodingException; import java.lang.reflect.InvocationTargetException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; @@ -51,6 +53,8 @@ public abstract class FileToolBase extends BaseTool { protected static Map compareEditorInputMap = new ConcurrentHashMap<>(); protected static Map fileContentCache = new ConcurrentHashMap<>(); + protected static Map localCompareEditorInputMap = new ConcurrentHashMap<>(); + protected static Map localFileContentCache = new ConcurrentHashMap<>(); @Override public abstract CompletableFuture invoke(Map input, ChatView chatView); @@ -62,8 +66,13 @@ protected void cleanupChangedFiles() { for (IFile file : compareEditorInputMap.keySet()) { closeCompareEditor(file); } + for (Path file : localCompareEditorInputMap.keySet()) { + closeCompareEditor(file); + } compareEditorInputMap.clear(); fileContentCache.clear(); + localCompareEditorInputMap.clear(); + localFileContentCache.clear(); } /** @@ -85,6 +94,43 @@ protected void cacheTheOriginalFileContent(IFile file) { } } + /** + * Caches the original content for a workspace 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(IFile file, String content) { + fileContentCache.putIfAbsent(file, content); + } + + /** + * Caches the original content of a local file to be compared with the proposed changes. + * + * @param file The local file whose original content is to be cached. + */ + protected void cacheTheOriginalFileContent(Path file) { + Path normalizedPath = normalizeLocalPath(file); + if (localFileContentCache.containsKey(normalizedPath)) { + return; + } + try { + localFileContentCache.put(normalizedPath, Files.readString(normalizedPath, StandardCharsets.UTF_8)); + } catch (IOException e) { + CopilotCore.LOGGER.error("Error caching original local file content", e); + } + } + + /** + * Caches the original content for a local file if no baseline exists yet. + * + * @param file The local file whose original content is to be cached. + * @param content The content to use as the original baseline. + */ + protected void cacheTheOriginalFileContent(Path file, String content) { + localFileContentCache.putIfAbsent(normalizeLocalPath(file), content); + } + /** * Validate the edit to ensure the files are writable. * @@ -129,6 +175,29 @@ protected void compareStringWithFile(String originalFileContent, IFile file) { } } + /** + * Compares the given string with the content of the given local file in a compare editor. + * + * @param originalFileContent The original string content of the file to compare with. + * @param file The local file with the proposed changes applied. + */ + protected void compareStringWithFile(String originalFileContent, Path file) { + Path normalizedPath = normalizeLocalPath(file); + try { + CompareEditorInput input = createCompareEditorInput(originalFileContent, normalizedPath); + input.run(new NullProgressMonitor()); + localCompareEditorInputMap.put(normalizedPath, input); + SwtUtils.invokeOnDisplayThreadAsync(() -> { + CompareEditorInput compareEditorInput = localCompareEditorInputMap.get(normalizedPath); + if (compareEditorInput != null) { + CompareUI.openCompareEditor(compareEditorInput); + } + }); + } catch (InvocationTargetException | InterruptedException e) { + CopilotCore.LOGGER.error("Error opening local file compare editor", e); + } + } + /** * Updates the current or creates a new compare editor with the given file content and file. * @@ -194,6 +263,37 @@ protected void refreshCompareEditorIfOpen(String fileContent, IFile file) { } } + /** + * Refreshes the compare editor for the given local file only if it is already open. Does not open a new editor or + * steal focus. + * + * @param fileContent The original file content to compare against. + * @param file The local file whose compare editor should be refreshed. + */ + protected void refreshCompareEditorIfOpen(String fileContent, Path file) { + if (fileContent == null) { + return; + } + Path normalizedPath = normalizeLocalPath(file); + CompareEditorInput input = localCompareEditorInputMap.get(normalizedPath); + if (input != null) { + CompareEditorInput newInput = createCompareEditorInput(fileContent, normalizedPath); + localCompareEditorInputMap.put(normalizedPath, newInput); + SwtUtils.invokeOnDisplayThreadAsync(() -> { + IEditorPart editor = getCompareEditor(input); + if (editor == null) { + localCompareEditorInputMap.remove(normalizedPath); + return; + } else { + CompareEditorInput compareEditorInput = localCompareEditorInputMap.get(normalizedPath); + if (compareEditorInput != null) { + CompareUI.reuseCompareEditor(compareEditorInput, (IReusableEditor) editor); + } + } + }); + } + } + /** * Brings the compare editor to the top of the workbench. * @@ -262,6 +362,58 @@ protected void closeCompareEditor(IFile file) { compareEditorInputMap.remove(file); } + /** + * Close the compare editor for the given local file if it is open. + * + * @param file The local file to check. + */ + protected void closeCompareEditor(Path file) { + Path normalizedPath = normalizeLocalPath(file); + CompareEditorInput input = localCompareEditorInputMap.get(normalizedPath); + if (input != null) { + SwtUtils.invokeOnDisplayThread(() -> { + IWorkbenchPage page = UiUtils.getActivePage(); + if (page == null) { + return; + } + IEditorReference[] editorRefs = page.getEditorReferences(); + for (IEditorReference ref : editorRefs) { + IEditorPart editor = ref.getEditor(false); + if (editor != null && editor.getEditorInput() == input) { + page.closeEditor(editor, false); + break; + } + } + }); + } + localCompareEditorInputMap.remove(normalizedPath); + } + + /** + * 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(); + } + + /** + * Gets the file extension for a local path. + * + * @param file the local file path + * @return the extension without the dot, or an empty string if none exists + */ + private 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); + } + private CompareEditorInput createCompareEditorInput(String comparedContent, IFile file) { // Create a new CompareConfiguration CompareConfiguration config = new CompareConfiguration(); @@ -326,6 +478,55 @@ public void saveChanges(IProgressMonitor monitor) throws CoreException { }; } + private CompareEditorInput createCompareEditorInput(String comparedContent, Path file) { + Path normalizedPath = normalizeLocalPath(file); + String fileName = normalizedPath.getFileName() == null ? normalizedPath.toString() + : normalizedPath.getFileName().toString(); + CompareConfiguration config = new CompareConfiguration(); + config.setLeftLabel(Messages.agent_tool_compareEditor_proposedChangesTitle.replaceAll("\"", "")); + config.setRightLabel(fileName); + 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 new CompareEditorInput(config) { + @Override + protected Object prepareInput(IProgressMonitor monitor) throws InvocationTargetException, InterruptedException { + monitor.beginTask("Calculating differences", 10); + setTitle(Messages.agent_tool_compareEditor_titlePrefix + fileName); + EditableStringCompareInput proposedChanges = new EditableStringCompareInput(comparedContent, fileName, + getLocalFileExtension(normalizedPath), StandardCharsets.UTF_8.name()); + EditableLocalFileCompareInput originalFile = new EditableLocalFileCompareInput(normalizedPath); + DiffNode diffNode = new DiffNode(null, Differencer.CHANGE, null, originalFile, proposedChanges); + monitor.done(); + return diffNode; + } + + @Override + public void saveChanges(IProgressMonitor monitor) throws CoreException { + if (isDirty()) { + config.setRightEditable(true); + super.saveChanges(monitor); + + DiffNode diffNode = (DiffNode) getCompareResult(); + if (diffNode != null) { + EditableLocalFileCompareInput inputToBeApplied = (EditableLocalFileCompareInput) 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(normalizedPath); + localFileContentCache.remove(normalizedPath); + } + } + }; + } + /** * Dispose the file change summary bar and related resources. */ @@ -337,6 +538,12 @@ protected void dispose() { if (fileContentCache != null) { fileContentCache.clear(); } + if (localCompareEditorInputMap != null) { + localCompareEditorInputMap.clear(); + } + if (localFileContentCache != null) { + localFileContentCache.clear(); + } } /** 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..c9d3c2c0 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 @@ -4,9 +4,9 @@ package com.microsoft.copilot.eclipse.ui.chat.tools; import java.io.IOException; +import java.nio.file.Path; import java.util.ArrayList; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import org.eclipse.core.databinding.observable.sideeffect.ISideEffect; @@ -35,7 +35,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 +78,7 @@ public void bindWorkingSetBar(ChatView chatView) { ensureRealm(() -> { unbindWorkingSetBar(); filesSideEffect = ISideEffect.create(() -> filesObservable.getValue(), - (Map filesMap) -> { + (Map filesMap) -> { if (filesMap.isEmpty()) { disposeWorkingSetBar(); } else { @@ -154,7 +154,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 +163,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,12 +175,17 @@ 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)) { + FileChangeProperty property = filesMap.get(file); + if (property.getChangeType() == FileChangeType.Created && fileChangeType == FileChangeType.Changed) { + property.setChangedAfterCreated(true); + filesObservable.setValue(filesMap); + } return; } filesMap.put(file, new FileChangeProperty(fileChangeType)); @@ -195,8 +200,26 @@ public void addChangedFile(IFile file, FileChangeType fileChangeType) { * @param file the file to complete */ public void completeFile(IFile file) { + completeFileInternal(ChangedFile.workspace(file)); + } + + /** + * Complete a changed local file action and remove it from the working set bar. + * + * @param file the local file to complete + */ + public void completeFile(Path file) { + completeFileInternal(ChangedFile.local(file)); + } + + /** + * Complete a changed file action and remove it from the working set bar. + * + * @param file the file to complete + */ + private void completeFileInternal(ChangedFile file) { ensureRealm(() -> { - Map filesMap = new LinkedHashMap<>(filesObservable.getValue()); + Map filesMap = new LinkedHashMap<>(filesObservable.getValue()); filesMap.remove(file); filesObservable.setValue(filesMap); @@ -212,7 +235,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,21 +249,42 @@ public FileChangeType getFileChangeTypeOf(IFile file) { * * @param file the file to keep changes for */ - public void onKeepChange(IFile file) { - if (getFileChangeTypeOf(file) == FileChangeType.Created) { - this.createFileTool.onKeepChange(file); - } else if (getFileChangeTypeOf(file) == FileChangeType.Changed) { - this.editFileTool.onKeepChange(file); + public void onKeepChange(ChangedFile file) { + if (getFileChangeTypeInternal(file) == FileChangeType.Created) { + if (file.isWorkspaceFile()) { + this.createFileTool.onKeepChange(file.getWorkspaceFile()); + } else { + this.createFileTool.onKeepChange(file.getLocalPath()); + } + } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { + if (file.isWorkspaceFile()) { + this.editFileTool.onKeepChange(file.getWorkspaceFile()); + } else { + this.editFileTool.onKeepChange(file.getLocalPath()); + } } - this.completeFile(file); + this.completeFileInternal(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) { + if (file.isWorkspaceFile()) { + this.createFileTool.onKeepChange(file.getWorkspaceFile()); + } else { + this.createFileTool.onKeepChange(file.getLocalPath()); + } + } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { + if (file.isWorkspaceFile()) { + this.editFileTool.onKeepChange(file.getWorkspaceFile()); + } else { + this.editFileTool.onKeepChange(file.getLocalPath()); + } + } + } onResolveAllChanges(); } @@ -249,17 +293,25 @@ 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) { - this.createFileTool.onUndoChange(file); - } else if (getFileChangeTypeOf(file) == FileChangeType.Changed) { - this.editFileTool.onUndoChange(file); + if (getFileChangeTypeInternal(file) == FileChangeType.Created) { + if (file.isWorkspaceFile()) { + this.createFileTool.onUndoChange(file.getWorkspaceFile()); + } else { + this.createFileTool.onUndoChange(file.getLocalPath()); + } + } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { + if (file.isWorkspaceFile()) { + this.editFileTool.onUndoChange(file.getWorkspaceFile()); + } else { + this.editFileTool.onUndoChange(file.getLocalPath()); + } } } catch (CoreException | IOException e) { CopilotCore.LOGGER.error("Error undoing changes for the new file", e); } - this.completeFile(file); + this.completeFileInternal(file); } /** @@ -267,8 +319,21 @@ 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) { + if (file.isWorkspaceFile()) { + this.createFileTool.onUndoChange(file.getWorkspaceFile()); + } else { + this.createFileTool.onUndoChange(file.getLocalPath()); + } + } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { + if (file.isWorkspaceFile()) { + this.editFileTool.onUndoChange(file.getWorkspaceFile()); + } else { + this.editFileTool.onUndoChange(file.getLocalPath()); + } + } + } } catch (CoreException | IOException e) { CopilotCore.LOGGER.error("Error undoing all changes for the files", e); } @@ -280,11 +345,27 @@ public void onUndoAllChanges() { * * @param file the file to view the diff for */ - public void onViewDiff(IFile file) { - if (getFileChangeTypeOf(file) == FileChangeType.Created) { - this.createFileTool.onViewDiff(file); - } else if (getFileChangeTypeOf(file) == FileChangeType.Changed) { - this.editFileTool.onViewDiff(file); + public void onViewDiff(ChangedFile file) { + FileChangeProperty property = filesObservable.getValue().get(file); + if (property == null) { + return; + } + if (property.getChangeType() == FileChangeType.Created) { + if (file.isWorkspaceFile()) { + if (property.isChangedAfterCreated()) { + this.editFileTool.onViewDiff(file.getWorkspaceFile()); + } else { + this.createFileTool.onViewDiff(file.getWorkspaceFile()); + } + } else { + this.createFileTool.onViewDiff(file.getLocalPath()); + } + } else if (property.getChangeType() == FileChangeType.Changed) { + if (file.isWorkspaceFile()) { + this.editFileTool.onViewDiff(file.getWorkspaceFile()); + } else { + this.editFileTool.onViewDiff(file.getLocalPath()); + } } } @@ -313,32 +394,13 @@ 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. changeType - The type of file change (new or edited). changedAfterCreated - + * Whether a created file has received subsequent edits. */ public static class FileChangeProperty { private FileChangeType changeType; + private boolean changedAfterCreated; /** * Constructor for FileChangeProperty. @@ -352,5 +414,13 @@ public FileChangeProperty(FileChangeType changeType) { public FileChangeType getChangeType() { return changeType; } + + public boolean isChangedAfterCreated() { + return changedAfterCreated; + } + + public void setChangedAfterCreated(boolean changedAfterCreated) { + this.changedAfterCreated = changedAfterCreated; + } } } 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..b1c6fceb 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,7 +4,7 @@ package com.microsoft.copilot.eclipse.ui.chat.tools; import java.io.IOException; -import java.util.List; +import java.nio.file.Path; import org.eclipse.core.resources.IFile; import org.eclipse.core.runtime.CoreException; @@ -21,9 +21,11 @@ public interface WorkingSetHandler { void onKeepChange(IFile file) throws IOException, CoreException; /** - * Handles the action of keeping all changes to files. + * Handles the action of keeping changes to a local file. + * + * @param file the local file to keep changes for */ - void onKeepAllChanges(List files) throws IOException, CoreException; + void onKeepChange(Path file) throws IOException, CoreException; /** * Handles the action of undoing changes to a file. @@ -36,12 +38,14 @@ public interface WorkingSetHandler { void onUndoChange(IFile file) throws CoreException, IOException; /** - * Handles the action of undoing all changes to files. + * Handles the action of undoing changes to a local file. + * + * @param file the local file to undo changes for * - * @throws CoreException if error occurs during the undo all operation, such as a failure to delete a file + * @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 onUndoAllChanges(List files) throws CoreException, IOException; + void onUndoChange(Path file) throws CoreException, IOException; /** * Handles the action of viewing the diff of a file. @@ -50,6 +54,13 @@ public interface WorkingSetHandler { */ void onViewDiff(IFile file); + /** + * Handles the action of viewing the diff of a local file. + * + * @param file the local file to view the diff for + */ + void onViewDiff(Path file); + /** * Handles the action of click done button to resolve all changes. */