From 084185778e287b836c867bae28f85ec794b8a284 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Thu, 21 May 2026 15:28:04 +0800 Subject: [PATCH 1/6] 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. */ From a2204902fa39b8f5d9ca915090b01ef430b6b363 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Mon, 25 May 2026 13:42:41 +0800 Subject: [PATCH 2/6] resolve comments --- .../eclipse/ui/chat/WorkingSetBarTest.java | 34 +++++------ .../ui/chat/tools/CreateFileToolTest.java | 61 +++++++++++++++++++ .../ui/chat/tools/EditFileToolTest.java | 15 ++--- .../eclipse/ui/chat/WorkingSetBar.java | 4 +- .../eclipse/ui/chat/tools/CreateFileTool.java | 9 ++- .../eclipse/ui/chat/tools/EditFileTool.java | 2 +- 6 files changed, 92 insertions(+), 33 deletions(-) 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 b8f09969..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 @@ -104,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); @@ -123,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); @@ -142,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); @@ -165,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); @@ -191,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); @@ -216,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); @@ -243,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); @@ -267,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"); @@ -276,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"); @@ -295,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); @@ -323,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); @@ -355,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); @@ -396,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); @@ -437,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); @@ -477,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"); @@ -489,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"); @@ -525,7 +525,7 @@ void testEmptyFilesMapDoesNotCreateChangedFiles() { /** * Creates a map of mock files with the specified count. */ - private Map createMockFilesMap(int count, boolean isHandled) { + private Map createMockFilesMap(int count) { Map filesMap = new LinkedHashMap<>(); for (int i = 0; i < count; i++) { IFile mockFile = createMockFile("TestFile" + i + ".java"); 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 6e6e8609..d08450cc 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,7 +5,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.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; @@ -86,6 +88,7 @@ void tearDown() throws Exception { // Clean up test project cleanupTestProject(); + FileToolCacheAccessor.clearCaches(); } private IProject setupTestProject() throws Exception { @@ -295,14 +298,49 @@ void testInvokeWithExternalLocalFileUriCreatesFile() throws Exception { verify(mockFileToolService).addChangedFile(ChangedFile.local(newFile), FileChangeType.Created); } + @Test + void testOnKeepChangeWithWorkspaceFileClearsOriginalContentCache() { + IFile newFile = mock(IFile.class); + FileToolCacheAccessor.putWorkspaceFileContentCache(newFile, ""); + + createFileTool.onKeepChange(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(newFile); + + assertTrue(!newFile.exists()); + assertNull(FileToolCacheAccessor.getWorkspaceFileContentCache(newFile)); + } + + @Test + void testOnKeepChangeWithExternalLocalFileClearsOriginalContentCache() { + Path newFile = tempDir.resolve("external-file-to-keep.txt"); + FileToolCacheAccessor.putLocalFileContentCache(newFile, ""); + + createFileTool.onKeepChange(newFile); + + assertNull(FileToolCacheAccessor.getLocalFileContentCache(newFile)); + } + @Test void testOnUndoChangeWithExternalLocalFileDeletesFile() throws Exception { Path newFile = tempDir.resolve("external-file-to-undo.txt"); Files.writeString(newFile, "test content"); + FileToolCacheAccessor.putLocalFileContentCache(newFile, ""); createFileTool.onUndoChange(newFile); assertTrue(Files.notExists(newFile)); + assertNull(FileToolCacheAccessor.getLocalFileContentCache(newFile)); } @Test @@ -340,4 +378,27 @@ 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 { + private static void clearCaches() { + FileToolBase.fileContentCache.clear(); + FileToolBase.localFileContentCache.clear(); + } + + private static void putWorkspaceFileContentCache(IFile file, String content) { + FileToolBase.fileContentCache.put(file, content); + } + + private static String getWorkspaceFileContentCache(IFile file) { + return FileToolBase.fileContentCache.get(file); + } + + private static void putLocalFileContentCache(Path file, String content) { + FileToolBase.localFileContentCache.put(file.toAbsolutePath().normalize(), content); + } + + private static String getLocalFileContentCache(Path file) { + return FileToolBase.localFileContentCache.get(file.toAbsolutePath().normalize()); + } + } } 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 index 51b8c8f3..5fd5634d 100644 --- 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 @@ -129,11 +129,6 @@ void testInvoke_withMissingExternalLocalFile_returnsError() throws Exception { } 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); } @@ -163,16 +158,14 @@ private void assertSuccess(LanguageModelToolResult[] results, String expectedCon assertEquals(expectedContent, results[0].getContent().get(0).getValue()); } - private static final class FileToolCacheAccessor extends EditFileTool { + private static final class FileToolCacheAccessor { private static void clearCaches() { - compareEditorInputMap.clear(); - fileContentCache.clear(); - localCompareEditorInputMap.clear(); - localFileContentCache.clear(); + FileToolBase.fileContentCache.clear(); + FileToolBase.localFileContentCache.clear(); } private static String getLocalFileContentCache(Path file) { - return localFileContentCache.get(file.toAbsolutePath().normalize()); + return FileToolBase.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 f0373b4a..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 @@ -302,6 +302,7 @@ 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) { @@ -313,6 +314,7 @@ public ChangedFiles(Composite parent, int style, Map labelProvider.dispose()); // Count files long fileCount = filesMap.size(); @@ -345,8 +347,6 @@ public ChangedFiles(Composite parent, int style, Map(); for (ChangedFile file : filesMap.keySet()) { if (file == null) { 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 991ef612..b0208556 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 @@ -66,7 +66,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. """); @@ -219,6 +219,7 @@ private void createParentFolders(IResource parent) throws CoreException { @Override public void onKeepChange(IFile file) { + fileContentCache.remove(file); closeCompareEditor(file); } @@ -229,7 +230,9 @@ public void onKeepChange(IFile file) { */ @Override public void onKeepChange(Path file) { - closeCompareEditor(file); + Path normalizedPath = normalizeLocalPath(file); + localFileContentCache.remove(normalizedPath); + closeCompareEditor(normalizedPath); } @Override @@ -237,6 +240,7 @@ public void onUndoChange(IFile file) throws CoreException { if (file != null && file.exists()) { file.delete(true, new NullProgressMonitor()); } + fileContentCache.remove(file); closeCompareEditor(file); } @@ -250,6 +254,7 @@ public void onUndoChange(IFile file) throws CoreException { public void onUndoChange(Path file) throws IOException { Path normalizedPath = normalizeLocalPath(file); Files.deleteIfExists(normalizedPath); + localFileContentCache.remove(normalizedPath); closeCompareEditor(normalizedPath); } 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 8263efdb..55cc75f4 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 @@ -57,7 +57,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, From ebd0ff285906064437dc741a6a4cb39e86dd4a00 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Mon, 25 May 2026 14:35:12 +0800 Subject: [PATCH 3/6] fix test failure --- .../eclipse/ui/chat/tools/CreateFileToolTest.java | 14 +++++++------- .../eclipse/ui/chat/tools/EditFileToolTest.java | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) 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 d08450cc..a1d68158 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 @@ -379,26 +379,26 @@ void testToolName() { * without complex mocking and would be better covered by integration tests. */ - private static final class FileToolCacheAccessor { + private static final class FileToolCacheAccessor extends CreateFileTool { private static void clearCaches() { - FileToolBase.fileContentCache.clear(); - FileToolBase.localFileContentCache.clear(); + fileContentCache.clear(); + localFileContentCache.clear(); } private static void putWorkspaceFileContentCache(IFile file, String content) { - FileToolBase.fileContentCache.put(file, content); + fileContentCache.put(file, content); } private static String getWorkspaceFileContentCache(IFile file) { - return FileToolBase.fileContentCache.get(file); + return fileContentCache.get(file); } private static void putLocalFileContentCache(Path file, String content) { - FileToolBase.localFileContentCache.put(file.toAbsolutePath().normalize(), content); + localFileContentCache.put(file.toAbsolutePath().normalize(), content); } private static String getLocalFileContentCache(Path file) { - return FileToolBase.localFileContentCache.get(file.toAbsolutePath().normalize()); + return localFileContentCache.get(file.toAbsolutePath().normalize()); } } } 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 index 5fd5634d..e713ecb1 100644 --- 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 @@ -158,14 +158,14 @@ private void assertSuccess(LanguageModelToolResult[] results, String expectedCon assertEquals(expectedContent, results[0].getContent().get(0).getValue()); } - private static final class FileToolCacheAccessor { + private static final class FileToolCacheAccessor extends EditFileTool { private static void clearCaches() { - FileToolBase.fileContentCache.clear(); - FileToolBase.localFileContentCache.clear(); + fileContentCache.clear(); + localFileContentCache.clear(); } private static String getLocalFileContentCache(Path file) { - return FileToolBase.localFileContentCache.get(file.toAbsolutePath().normalize()); + return localFileContentCache.get(file.toAbsolutePath().normalize()); } } } \ No newline at end of file From a2aa769b93c9a324c153eeced4ef14b0d777fea8 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Tue, 26 May 2026 17:57:15 +0800 Subject: [PATCH 4/6] merge local and IFile related logic --- .../ui/chat/tools/CreateFileToolTest.java | 29 +- .../ui/chat/tools/EditFileToolTest.java | 11 +- .../eclipse/ui/chat/tools/CreateFileTool.java | 78 +--- .../eclipse/ui/chat/tools/EditFileTool.java | 104 ++--- .../tools/EditableLocalFileCompareInput.java | 135 ------ .../eclipse/ui/chat/tools/FileToolBase.java | 435 +++++++----------- .../ui/chat/tools/FileToolService.java | 90 +--- .../ui/chat/tools/WorkingSetHandler.java | 32 +- 8 files changed, 255 insertions(+), 659 deletions(-) delete mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditableLocalFileCompareInput.java 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 a1d68158..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 @@ -303,7 +303,7 @@ void testOnKeepChangeWithWorkspaceFileClearsOriginalContentCache() { IFile newFile = mock(IFile.class); FileToolCacheAccessor.putWorkspaceFileContentCache(newFile, ""); - createFileTool.onKeepChange(newFile); + createFileTool.onKeepChange(ChangedFile.workspace(newFile)); assertNull(FileToolCacheAccessor.getWorkspaceFileContentCache(newFile)); } @@ -315,7 +315,7 @@ void testOnUndoChangeWithWorkspaceFileDeletesFileAndClearsOriginalContentCache() newFile.create(new java.io.ByteArrayInputStream("test content".getBytes()), true, null); FileToolCacheAccessor.putWorkspaceFileContentCache(newFile, ""); - createFileTool.onUndoChange(newFile); + createFileTool.onUndoChange(ChangedFile.workspace(newFile)); assertTrue(!newFile.exists()); assertNull(FileToolCacheAccessor.getWorkspaceFileContentCache(newFile)); @@ -324,23 +324,23 @@ void testOnUndoChangeWithWorkspaceFileDeletesFileAndClearsOriginalContentCache() @Test void testOnKeepChangeWithExternalLocalFileClearsOriginalContentCache() { Path newFile = tempDir.resolve("external-file-to-keep.txt"); - FileToolCacheAccessor.putLocalFileContentCache(newFile, ""); + FileToolCacheAccessor.putFileContentCache(newFile, ""); - createFileTool.onKeepChange(newFile); + createFileTool.onKeepChange(ChangedFile.local(newFile)); - assertNull(FileToolCacheAccessor.getLocalFileContentCache(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.putLocalFileContentCache(newFile, ""); + FileToolCacheAccessor.putFileContentCache(newFile, ""); - createFileTool.onUndoChange(newFile); + createFileTool.onUndoChange(ChangedFile.local(newFile)); assertTrue(Files.notExists(newFile)); - assertNull(FileToolCacheAccessor.getLocalFileContentCache(newFile)); + assertNull(FileToolCacheAccessor.getFileContentCache(newFile)); } @Test @@ -382,23 +382,22 @@ void testToolName() { private static final class FileToolCacheAccessor extends CreateFileTool { private static void clearCaches() { fileContentCache.clear(); - localFileContentCache.clear(); } private static void putWorkspaceFileContentCache(IFile file, String content) { - fileContentCache.put(file, content); + fileContentCache.put(ChangedFile.workspace(file), content); } private static String getWorkspaceFileContentCache(IFile file) { - return fileContentCache.get(file); + return fileContentCache.get(ChangedFile.workspace(file)); } - private static void putLocalFileContentCache(Path file, String content) { - localFileContentCache.put(file.toAbsolutePath().normalize(), content); + private static void putFileContentCache(Path file, String content) { + fileContentCache.put(ChangedFile.local(file), content); } - private static String getLocalFileContentCache(Path file) { - return localFileContentCache.get(file.toAbsolutePath().normalize()); + 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 index e713ecb1..4ffabcbd 100644 --- 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 @@ -95,7 +95,7 @@ void testOnUndoChange_withExternalLocalFile_restoresOriginalContent() throws Exc LanguageModelToolResult[] results = invokeEdit(editFileTool, file.toString(), "updated"); assertSuccess(results, "updated"); - editFileTool.onUndoChange(file); + editFileTool.onUndoChange(ChangedFile.local(file)); assertEquals("original", Files.readString(file)); } @@ -109,14 +109,14 @@ void testInvoke_createThenEditExternalLocalFile_preservesEmptyBaseline() throws CreateFileTool createFileTool = new CreateFileTool(); LanguageModelToolResult[] createResults = invokeCreate(createFileTool, file.toString(), "created content"); assertSuccess(createResults, "File created at: " + normalizedPath); - assertEquals("", FileToolCacheAccessor.getLocalFileContentCache(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.getLocalFileContentCache(normalizedPath)); + assertEquals("", FileToolCacheAccessor.getFileContentCache(normalizedPath)); } @Test @@ -161,11 +161,10 @@ private void assertSuccess(LanguageModelToolResult[] results, String expectedCon private static final class FileToolCacheAccessor extends EditFileTool { private static void clearCaches() { fileContentCache.clear(); - localFileContentCache.clear(); } - private static String getLocalFileContentCache(Path file) { - return localFileContentCache.get(file.toAbsolutePath().normalize()); + 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/tools/CreateFileTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java index b0208556..68d38419 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 @@ -19,7 +19,6 @@ 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; @@ -138,7 +137,7 @@ private LanguageModelToolResult createWorkspaceFile(IFile file, String filePath, try (ByteArrayInputStream contentStream = new ByteArrayInputStream( content.getBytes(PlatformUtils.getFileCharset(file)))) { file.create(contentStream, IResource.FORCE, new NullProgressMonitor()); - cacheTheOriginalFileContent(file, StringUtils.EMPTY); + cacheTheOriginalFileContent(ChangedFile.workspace(file), StringUtils.EMPTY); } CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(ChangedFile.workspace(file), FileChangeType.Created); @@ -172,7 +171,7 @@ private LanguageModelToolResult createLocalFile(Path filePath, String content) { Files.createDirectories(parent); } Files.writeString(normalizedPath, content, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW); - cacheTheOriginalFileContent(normalizedPath, StringUtils.EMPTY); + cacheTheOriginalFileContent(ChangedFile.local(normalizedPath), StringUtils.EMPTY); CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile( ChangedFile.local(normalizedPath), FileChangeType.Created); result.addContent("File created at: " + normalizedPath); @@ -218,68 +217,39 @@ private void createParentFolders(IResource parent) throws CoreException { } @Override - public void onKeepChange(IFile file) { - fileContentCache.remove(file); + public void onKeepChange(ChangedFile file) { + removeCachedFileContent(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 onKeepChange(Path file) { - Path normalizedPath = normalizeLocalPath(file); - localFileContentCache.remove(normalizedPath); - closeCompareEditor(normalizedPath); - } - @Override - public void onUndoChange(IFile file) throws CoreException { - if (file != null && file.exists()) { - file.delete(true, new NullProgressMonitor()); - } - fileContentCache.remove(file); + public void onUndoChange(ChangedFile file) throws CoreException, IOException { + deleteCreatedFile(file); + removeCachedFileContent(file); 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); - localFileContentCache.remove(normalizedPath); - closeCompareEditor(normalizedPath); - } - - @Override - public void onViewDiff(IFile file) { - SwtUtils.invokeOnDisplayThreadAsync(() -> UiUtils.openInEditor(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()); } - /** - * 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); + public void onViewDiff(ChangedFile file) { + if (file.isWorkspaceFile()) { + SwtUtils.invokeOnDisplayThreadAsync(() -> UiUtils.openInEditor(file.getWorkspaceFile())); + return; + } + if (bringCompareEditorToTopIfOpen(file)) { + return; } - compareStringWithFile("", normalizedPath); + compareStringWithFile("", file); } @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 55cc75f4..cc1015fd 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 @@ -18,7 +18,6 @@ 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; @@ -161,9 +160,10 @@ private LanguageModelToolResult[] editFile(String filePath, String code) { } private LanguageModelToolResult[] editWorkspaceFile(IFile file, String code) { - CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(ChangedFile.workspace(file), + ChangedFile changedFile = ChangedFile.workspace(file); + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(changedFile, FileChangeType.Changed); - cacheTheOriginalFileContent(file); + cacheTheOriginalFileContent(changedFile); try { applyChangesToFile(code, file); } catch (CoreException | IOException e) { @@ -171,18 +171,19 @@ private LanguageModelToolResult[] editWorkspaceFile(IFile file, String code) { return new LanguageModelToolResult[] { new LanguageModelToolResult( "Failed to apply changes to the file: " + e.getMessage(), ToolInvocationStatus.error) }; } - refreshCompareEditorIfOpen(fileContentCache.get(file), file); + 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 { - CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile( - ChangedFile.local(normalizedPath), FileChangeType.Changed); - cacheTheOriginalFileContent(normalizedPath); + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(changedFile, + FileChangeType.Changed); + cacheTheOriginalFileContent(changedFile); Files.writeString(normalizedPath, code, StandardCharsets.UTF_8); - refreshCompareEditorIfOpen(localFileContentCache.get(normalizedPath), normalizedPath); + refreshCompareEditorIfOpen(getCachedFileContent(changedFile), changedFile); return new LanguageModelToolResult[] { new LanguageModelToolResult(code, ToolInvocationStatus.success) }; } catch (IOException e) { CopilotCore.LOGGER.error("Error replacing local file content", e); @@ -233,93 +234,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); } - /** - * Handles the action of keeping changes to a local file. - * - * @param file the local file to keep changes for - */ @Override - public void onKeepChange(Path file) { - Path normalizedPath = normalizeLocalPath(file); - localFileContentCache.remove(normalizedPath); - closeCompareEditor(normalizedPath); - } - - @Override - public void onUndoChange(IFile file) throws CoreException, IOException { + public void onUndoChange(ChangedFile file) throws CoreException, IOException { undoChangesToFile(file); 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 onUndoChange(Path file) throws IOException { - undoChangesToFile(file); - closeCompareEditor(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); + public void onViewDiff(ChangedFile file) { + if (bringCompareEditorToTopIfOpen(file)) { + return; } - compareStringWithFile(fileContentCache.get(file), file); + compareStringWithFile(getCachedFileContent(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); + 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(localFileContentCache.get(normalizedPath), normalizedPath); + 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); - } - - 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 deleted file mode 100644 index 79100b50..00000000 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditableLocalFileCompareInput.java +++ /dev/null @@ -1,135 +0,0 @@ -// 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 beeaac32..7b94b600 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 @@ -15,6 +15,7 @@ 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; @@ -32,6 +33,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; @@ -51,10 +53,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 localCompareEditorInputMap = new ConcurrentHashMap<>(); - protected static Map localFileContentCache = new ConcurrentHashMap<>(); + protected static Map compareEditorInputMap = new ConcurrentHashMap<>(); + protected static Map fileContentCache = new ConcurrentHashMap<>(); @Override public abstract CompletableFuture invoke(Map input, ChatView chatView); @@ -63,72 +63,68 @@ public abstract class FileToolBase extends BaseTool { * Common method to handle cleanup of file changes. */ protected void cleanupChangedFiles() { - for (IFile file : compareEditorInputMap.keySet()) { - closeCompareEditor(file); - } - for (Path file : localCompareEditorInputMap.keySet()) { + for (ChangedFile file : compareEditorInputMap.keySet()) { closeCompareEditor(file); } compareEditorInputMap.clear(); fileContentCache.clear(); - localCompareEditorInputMap.clear(); - localFileContentCache.clear(); } /** - * 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 workspace file if no baseline exists yet. + * 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(IFile file, String content) { + 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); + } + /** - * Caches the original content of a local file to be compared with the proposed changes. + * Gets the cached original content for a changed file. * - * @param file The local file whose original content is to be cached. + * @param file The changed file whose cached content should be returned. + * @return the cached content, or null if no content is 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); - } + protected String getCachedFileContent(ChangedFile file) { + return fileContentCache.get(file); } /** - * Caches the original content for a local file if no baseline exists yet. + * Removes the cached original content for a changed file. * - * @param file The local file whose original content is to be cached. - * @param content The content to use as the original baseline. + * @param file The changed file whose cached content should be removed. */ - protected void cacheTheOriginalFileContent(Path file, String content) { - localFileContentCache.putIfAbsent(normalizeLocalPath(file), content); + protected void removeCachedFileContent(ChangedFile file) { + fileContentCache.remove(file); } /** @@ -151,14 +147,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()); @@ -176,70 +170,12 @@ 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. - * - * @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; } @@ -253,42 +189,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); - } } - }); - } - } - - /** - * 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); - } + CompareEditorInput compareEditorInput = compareEditorInputMap.get(file); + if (compareEditorInput != null) { + CompareUI.reuseCompareEditor(compareEditorInput, (IReusableEditor) editor); } }); } @@ -336,12 +240,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(() -> { @@ -363,30 +266,22 @@ protected void closeCompareEditor(IFile file) { } /** - * Close the compare editor for the given local file if it is open. + * Brings the compare editor for a changed file to the top if it is open. * - * @param file The local file to check. + * @param file The changed file whose compare editor should be shown. + * @return true if an open compare editor was found, false otherwise. */ - 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; - } - } - }); + protected boolean bringCompareEditorToTopIfOpen(ChangedFile file) { + CompareEditorInput input = compareEditorInputMap.get(file); + if (input == null) { + return false; } - localCompareEditorInputMap.remove(normalizedPath); + if (isCompareEditorOpen(input)) { + bringCompareEditorToTop(input); + return true; + } + compareEditorInputMap.remove(file); + return false; } /** @@ -399,132 +294,104 @@ 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 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 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()); + 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); + }); + } - // 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); + private CompareEditorInput createCompareEditorInput(String comparedContent, ChangedFile file) { + if (file.isWorkspaceFile()) { + return createWorkspaceCompareEditorInput(comparedContent, file.getWorkspaceFile()); + } + return createLocalCompareEditorInput(comparedContent, file.getLocalPath()); + } - // 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); + 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 CompareEditorInput createCompareEditorInput(String comparedContent, Path file) { - Path normalizedPath = normalizeLocalPath(file); - String fileName = normalizedPath.getFileName() == null ? normalizedPath.toString() - : normalizedPath.getFileName().toString(); + private CompareConfiguration createCompareConfiguration(String rightLabel) { CompareConfiguration config = new CompareConfiguration(); config.setLeftLabel(Messages.agent_tool_compareEditor_proposedChangesTitle.replaceAll("\"", "")); - config.setRightLabel(fileName); + 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; + } - 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); - } - } - }; + /** + * 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; } /** @@ -538,19 +405,15 @@ protected void dispose() { if (fileContentCache != null) { fileContentCache.clear(); } - if (localCompareEditorInputMap != null) { - localCompareEditorInputMap.clear(); - } - if (localFileContentCache != null) { - localFileContentCache.clear(); - } } /** * 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; /** @@ -559,12 +422,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 @@ -574,11 +452,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 @@ -586,12 +472,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 @@ -608,7 +501,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); @@ -616,6 +508,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 c9d3c2c0..eb1828e0 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,7 +4,6 @@ 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.Map; @@ -12,7 +11,6 @@ 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; @@ -199,25 +197,7 @@ public void addChangedFile(ChangedFile 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) { + public void completeFile(ChangedFile file) { ensureRealm(() -> { Map filesMap = new LinkedHashMap<>(filesObservable.getValue()); filesMap.remove(file); @@ -251,19 +231,11 @@ private FileChangeType getFileChangeTypeInternal(ChangedFile 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()); - } + this.createFileTool.onKeepChange(file); } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { - if (file.isWorkspaceFile()) { - this.editFileTool.onKeepChange(file.getWorkspaceFile()); - } else { - this.editFileTool.onKeepChange(file.getLocalPath()); - } + this.editFileTool.onKeepChange(file); } - this.completeFileInternal(file); + this.completeFile(file); } /** @@ -272,17 +244,9 @@ public void onKeepChange(ChangedFile file) { public void onKeepAllChanges() { 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()); - } + this.createFileTool.onKeepChange(file); } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { - if (file.isWorkspaceFile()) { - this.editFileTool.onKeepChange(file.getWorkspaceFile()); - } else { - this.editFileTool.onKeepChange(file.getLocalPath()); - } + this.editFileTool.onKeepChange(file); } } onResolveAllChanges(); @@ -296,22 +260,14 @@ public void onKeepAllChanges() { public void onUndoChange(ChangedFile file) { try { if (getFileChangeTypeInternal(file) == FileChangeType.Created) { - if (file.isWorkspaceFile()) { - this.createFileTool.onUndoChange(file.getWorkspaceFile()); - } else { - this.createFileTool.onUndoChange(file.getLocalPath()); - } + this.createFileTool.onUndoChange(file); } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { - if (file.isWorkspaceFile()) { - this.editFileTool.onUndoChange(file.getWorkspaceFile()); - } else { - this.editFileTool.onUndoChange(file.getLocalPath()); - } + this.editFileTool.onUndoChange(file); } } catch (CoreException | IOException e) { CopilotCore.LOGGER.error("Error undoing changes for the new file", e); } - this.completeFileInternal(file); + this.completeFile(file); } /** @@ -321,17 +277,9 @@ public void onUndoAllChanges() { try { 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()); - } + this.createFileTool.onUndoChange(file); } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { - if (file.isWorkspaceFile()) { - this.editFileTool.onUndoChange(file.getWorkspaceFile()); - } else { - this.editFileTool.onUndoChange(file.getLocalPath()); - } + this.editFileTool.onUndoChange(file); } } } catch (CoreException | IOException e) { @@ -351,21 +299,13 @@ public void onViewDiff(ChangedFile file) { return; } if (property.getChangeType() == FileChangeType.Created) { - if (file.isWorkspaceFile()) { - if (property.isChangedAfterCreated()) { - this.editFileTool.onViewDiff(file.getWorkspaceFile()); - } else { - this.createFileTool.onViewDiff(file.getWorkspaceFile()); - } + if (property.isChangedAfterCreated()) { + this.editFileTool.onViewDiff(file); } else { - this.createFileTool.onViewDiff(file.getLocalPath()); + this.createFileTool.onViewDiff(file); } } else if (property.getChangeType() == FileChangeType.Changed) { - if (file.isWorkspaceFile()) { - this.editFileTool.onViewDiff(file.getWorkspaceFile()); - } else { - this.editFileTool.onViewDiff(file.getLocalPath()); - } + this.editFileTool.onViewDiff(file); } } 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 b1c6fceb..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.nio.file.Path; -import org.eclipse.core.resources.IFile; import org.eclipse.core.runtime.CoreException; /** @@ -18,14 +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 changes to a local file. - * - * @param file the local file to keep changes for - */ - void onKeepChange(Path file) throws IOException, CoreException; + void onKeepChange(ChangedFile file) throws IOException, CoreException; /** * Handles the action of undoing changes to a file. @@ -35,31 +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 changes to a local file. - * - * @param file the local file to undo changes for - * - * @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(Path file) 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); - - /** - * 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); + void onViewDiff(ChangedFile file); /** * Handles the action of click done button to resolve all changes. From 2112998273daea5bab46fa209e3db954d156e826 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Wed, 27 May 2026 10:28:19 +0800 Subject: [PATCH 5/6] extract shared function --- .../eclipse/ui/chat/tools/CreateFileTool.java | 16 ------------- .../eclipse/ui/chat/tools/EditFileTool.java | 24 +++++-------------- .../eclipse/ui/chat/tools/FileToolBase.java | 22 +++++++++++++++++ 3 files changed, 28 insertions(+), 34 deletions(-) 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 68d38419..04aab68f 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,10 @@ 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; @@ -185,19 +182,6 @@ private LanguageModelToolResult createLocalFile(Path filePath, String content) { 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; - } - } - /** * Creates parent folders if they don't exist. * 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 cc1015fd..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 @@ -6,13 +6,10 @@ 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.Map; @@ -179,10 +176,14 @@ 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); - cacheTheOriginalFileContent(changedFile); - Files.writeString(normalizedPath, code, StandardCharsets.UTF_8); refreshCompareEditorIfOpen(getCachedFileContent(changedFile), changedFile); return new LanguageModelToolResult[] { new LanguageModelToolResult(code, ToolInvocationStatus.success) }; } catch (IOException e) { @@ -192,19 +193,6 @@ private LanguageModelToolResult[] editLocalFile(Path filePath, String code) { } } - 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()); 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 7b94b600..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,9 +8,12 @@ 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; @@ -294,6 +297,25 @@ protected Path normalizeLocalPath(Path file) { return file.toAbsolutePath().normalize(); } + /** + * 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); From 48500b108ec39e293c34d6895d9dc362b1b2a982 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Thu, 28 May 2026 10:35:59 +0800 Subject: [PATCH 6/6] open local file in eclipse --- .../local-file-edit-and-create-tools.md | 10 ++++---- .../eclipse/ui/chat/tools/CreateFileTool.java | 5 +--- .../ui/chat/tools/FileToolService.java | 23 ++--------------- .../copilot/eclipse/ui/utils/UiUtils.java | 25 +++++++++++++++++++ 4 files changed, 33 insertions(+), 30 deletions(-) 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 index d9ed886e..ae46796c 100644 --- 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 @@ -137,7 +137,7 @@ Not exercised: ## 2. Create a new local file outside the workspace -### TC-004: Agent creates a local file and shows an empty-baseline diff +### TC-004: Agent creates a local file and opens it from the summary bar **Type:** `Happy Path` **Priority:** `P0` @@ -154,18 +154,18 @@ Not exercised: 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. +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 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. +- 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 file diff** -- The Compare editor shows empty original content vs. the created content. +- [ ] **Created local file editor** -- The external local file opens in an editor with the created content. ### TC-005: Undo removes a created local 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 04aab68f..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 @@ -230,10 +230,7 @@ public void onViewDiff(ChangedFile file) { SwtUtils.invokeOnDisplayThreadAsync(() -> UiUtils.openInEditor(file.getWorkspaceFile())); return; } - if (bringCompareEditorToTopIfOpen(file)) { - return; - } - compareStringWithFile("", file); + SwtUtils.invokeOnDisplayThreadAsync(() -> UiUtils.openLocalFileInEditor(file.getLocalPath())); } @Override 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 eb1828e0..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 @@ -179,11 +179,6 @@ public void addChangedFile(ChangedFile file, FileChangeType fileChangeType) { ensureRealm(() -> { 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)); @@ -299,11 +294,7 @@ public void onViewDiff(ChangedFile file) { return; } if (property.getChangeType() == FileChangeType.Created) { - if (property.isChangedAfterCreated()) { - this.editFileTool.onViewDiff(file); - } else { - this.createFileTool.onViewDiff(file); - } + this.createFileTool.onViewDiff(file); } else if (property.getChangeType() == FileChangeType.Changed) { this.editFileTool.onViewDiff(file); } @@ -335,12 +326,10 @@ public void disposeWorkingSetBar() { } /** - * Class for file change properties. changeType - The type of file change (new or edited). changedAfterCreated - - * Whether a created file has received subsequent edits. + * Class for file change properties. */ public static class FileChangeProperty { private FileChangeType changeType; - private boolean changedAfterCreated; /** * Constructor for FileChangeProperty. @@ -354,13 +343,5 @@ 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/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. */