diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/utils/FileUtilsTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/utils/FileUtilsTests.java new file mode 100644 index 00000000..cbbf6d55 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/utils/FileUtilsTests.java @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class FileUtilsTests { + + @Test + void testGetLocalFilePath_absolutePath_returnsNormalizedPath(@TempDir Path tempDir) { + Path expected = tempDir.resolve("external-file.txt").toAbsolutePath().normalize(); + + assertEquals(expected, FileUtils.getLocalFilePath(expected.toString())); + } + + @Test + void testGetLocalFilePath_fileUriWithFragment_ignoresFragment(@TempDir Path tempDir) { + Path expected = tempDir.resolve("external-file.txt").toAbsolutePath().normalize(); + + assertEquals(expected, FileUtils.getLocalFilePath(expected.toUri() + "#L10")); + } + + @Test + void testGetLocalFilePath_relativePath_returnsNull() { + assertNull(FileUtils.getLocalFilePath("src/main/java/File.java")); + } + + @Test + void testGetLocalFilePath_nonFileUri_returnsNull() { + assertNull(FileUtils.getLocalFilePath("https://example.com/file.java")); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java index 3dd51c39..06bbb611 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java @@ -181,6 +181,53 @@ public static IFile getFileFromUri(String fileUri) { return null; } + /** + * 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 + */ + @Nullable + public static Path getLocalFilePath(String filePath) { + if (StringUtils.isBlank(filePath)) { + return null; + } + + try { + // For file URIs, '#' is always a fragment delimiter (literal '#' in filenames is encoded as %23). + if (filePath.startsWith("file:")) { + String uriWithoutFragment = stripFragment(filePath); + return Paths.get(new URI(uriWithoutFragment)).toAbsolutePath().normalize(); + } + + String pathWithoutFragment = stripFragment(filePath); + if (URI_SCHEME_PATTERN.matcher(pathWithoutFragment).find() && !hasDriveLetter(pathWithoutFragment)) { + return null; + } + + // For raw paths, try the full string first since '#' is a valid filename character on Unix/Linux. + // Only fall back to stripping the fragment if the full path doesn't exist. + Path fullPath = Paths.get(filePath); + if (fullPath.isAbsolute()) { + if (Files.exists(fullPath)) { + return fullPath.toAbsolutePath().normalize(); + } + // Fall back: treat '#...' as a line-number fragment + Path strippedPath = Paths.get(pathWithoutFragment); + return strippedPath.isAbsolute() ? strippedPath.toAbsolutePath().normalize() : null; + } + return null; + } catch (IllegalArgumentException | URISyntaxException e) { + CopilotCore.LOGGER.error("Invalid local file path: " + filePath, e); + return null; + } + } + + private static String stripFragment(String pathOrUri) { + int fragmentIndex = pathOrUri.indexOf('#'); + return fragmentIndex > 0 ? pathOrUri.substring(0, fragmentIndex) : pathOrUri; + } + /** * Normalizes a file path or URI string to a proper file URI string. Handles Windows absolute paths, POSIX absolute * paths, and existing URI strings. Line number fragments (e.g., #L123) are preserved. 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 ae46796c..7f9a18cf 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 @@ -192,3 +192,30 @@ Not exercised: #### 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. + +--- + +## 3. Navigate to local files from tool links + +### TC-006: Tool result links open local files outside the workspace + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in Agent mode. +- The local test directory outside the workspace exists and contains `existing-local-file.txt`. + +#### Steps +1. Send a prompt that causes Agent mode to reference or edit `existing-local-file.txt` by absolute path. +2. When the tool call appears in the Chat view, click the file path link for `existing-local-file.txt`. +3. Verify Eclipse opens `existing-local-file.txt` in an editor. + +#### Expected Result +- File links for paths outside the Eclipse workspace open the local file in an Eclipse editor. +- No error dialog is shown and the Eclipse error log has no local file navigation exception. + +#### Key Screenshots +- [ ] **Local file tool link** -- The tool result shows a clickable absolute path outside the workspace. +- [ ] **External local file editor** -- The external local file opens in an Eclipse editor. diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/FileAnnotationHyperlinkDetector.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/FileAnnotationHyperlinkDetector.java index eef5dc17..2a4b377b 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/FileAnnotationHyperlinkDetector.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/FileAnnotationHyperlinkDetector.java @@ -3,10 +3,13 @@ package com.microsoft.copilot.eclipse.ui.chat; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; + import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IResource; import org.eclipse.core.resources.ResourcesPlugin; -import org.eclipse.core.runtime.Path; import org.eclipse.jface.text.IRegion; import org.eclipse.jface.text.hyperlink.IHyperlink; import org.eclipse.jface.text.hyperlink.URLHyperlink; @@ -59,14 +62,27 @@ public void open() { String urlString = getURLString(); if (urlString.startsWith(LSPEclipseUtils.FILE_URI)) { IResource targetResource = LSPEclipseUtils.findResourceFor(urlString); - if (targetResource != null && targetResource.getType() == IResource.FILE) { - Location location = new Location(); - location.setUri(urlString); - LSPEclipseUtils.openInEditor(location); + if (targetResource != null) { + if (targetResource.getType() == IResource.FILE) { + Location location = new Location(); + location.setUri(urlString); + LSPEclipseUtils.openInEditor(location); + return; + } + if (targetResource.getType() == IResource.FOLDER + || targetResource.getType() == IResource.PROJECT) { + UiUtils.revealInExplorer(targetResource); + return; + } + } + Path localPath = FileUtils.getLocalFilePath(urlString); + if (localPath != null && Files.isRegularFile(localPath, LinkOption.NOFOLLOW_LINKS)) { + UiUtils.openLocalFileInEditor(localPath); return; } } else { - IFile file = ResourcesPlugin.getWorkspace().getRoot().getFile(new Path(urlString)); + IFile file = ResourcesPlugin.getWorkspace().getRoot() + .getFile(new org.eclipse.core.runtime.Path(urlString)); if (file.exists()) { var workbenchWindow = PlatformUI.getWorkbench().getActiveWorkbenchWindow(); 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 c7795aeb..ca9485dd 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 @@ -108,7 +108,7 @@ private LanguageModelToolResult createFile(String filePath, String content) { return createWorkspaceFile(file, filePath, content); } - Path localPath = getLocalFilePath(filePath); + Path localPath = FileUtils.getLocalFilePath(filePath); if (localPath != null) { return createLocalFile(localPath, content); } 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 6a20fcd6..1260b0a1 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 @@ -146,7 +146,7 @@ private LanguageModelToolResult[] editFile(String filePath, String code) { return editWorkspaceFile(file, code); } - Path localPath = getLocalFilePath(filePath); + Path localPath = FileUtils.getLocalFilePath(filePath); if (localPath != null && Files.isRegularFile(localPath, LinkOption.NOFOLLOW_LINKS)) { return editLocalFile(localPath, code); } 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 3ad526bf..8d545fd7 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,12 +8,9 @@ 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; @@ -297,24 +294,7 @@ 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);