Skip to content

Commit 5b2397d

Browse files
authored
fix: File and directory link navigation in chat tool results (#272)
1 parent 4244f7b commit 5b2397d

7 files changed

Lines changed: 138 additions & 29 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
package com.microsoft.copilot.eclipse.core.utils;
5+
6+
import static org.junit.jupiter.api.Assertions.assertEquals;
7+
import static org.junit.jupiter.api.Assertions.assertNull;
8+
9+
import java.nio.file.Path;
10+
11+
import org.junit.jupiter.api.Test;
12+
import org.junit.jupiter.api.io.TempDir;
13+
14+
class FileUtilsTests {
15+
16+
@Test
17+
void testGetLocalFilePath_absolutePath_returnsNormalizedPath(@TempDir Path tempDir) {
18+
Path expected = tempDir.resolve("external-file.txt").toAbsolutePath().normalize();
19+
20+
assertEquals(expected, FileUtils.getLocalFilePath(expected.toString()));
21+
}
22+
23+
@Test
24+
void testGetLocalFilePath_fileUriWithFragment_ignoresFragment(@TempDir Path tempDir) {
25+
Path expected = tempDir.resolve("external-file.txt").toAbsolutePath().normalize();
26+
27+
assertEquals(expected, FileUtils.getLocalFilePath(expected.toUri() + "#L10"));
28+
}
29+
30+
@Test
31+
void testGetLocalFilePath_relativePath_returnsNull() {
32+
assertNull(FileUtils.getLocalFilePath("src/main/java/File.java"));
33+
}
34+
35+
@Test
36+
void testGetLocalFilePath_nonFileUri_returnsNull() {
37+
assertNull(FileUtils.getLocalFilePath("https://example.com/file.java"));
38+
}
39+
}

com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,53 @@ public static IFile getFileFromUri(String fileUri) {
181181
return null;
182182
}
183183

184+
/**
185+
* Resolves an absolute local filesystem path from a path or file URI.
186+
*
187+
* @param filePath the path or URI to resolve
188+
* @return the local filesystem path, or null if the input is not an absolute local path
189+
*/
190+
@Nullable
191+
public static Path getLocalFilePath(String filePath) {
192+
if (StringUtils.isBlank(filePath)) {
193+
return null;
194+
}
195+
196+
try {
197+
// For file URIs, '#' is always a fragment delimiter (literal '#' in filenames is encoded as %23).
198+
if (filePath.startsWith("file:")) {
199+
String uriWithoutFragment = stripFragment(filePath);
200+
return Paths.get(new URI(uriWithoutFragment)).toAbsolutePath().normalize();
201+
}
202+
203+
String pathWithoutFragment = stripFragment(filePath);
204+
if (URI_SCHEME_PATTERN.matcher(pathWithoutFragment).find() && !hasDriveLetter(pathWithoutFragment)) {
205+
return null;
206+
}
207+
208+
// For raw paths, try the full string first since '#' is a valid filename character on Unix/Linux.
209+
// Only fall back to stripping the fragment if the full path doesn't exist.
210+
Path fullPath = Paths.get(filePath);
211+
if (fullPath.isAbsolute()) {
212+
if (Files.exists(fullPath)) {
213+
return fullPath.toAbsolutePath().normalize();
214+
}
215+
// Fall back: treat '#...' as a line-number fragment
216+
Path strippedPath = Paths.get(pathWithoutFragment);
217+
return strippedPath.isAbsolute() ? strippedPath.toAbsolutePath().normalize() : null;
218+
}
219+
return null;
220+
} catch (IllegalArgumentException | URISyntaxException e) {
221+
CopilotCore.LOGGER.error("Invalid local file path: " + filePath, e);
222+
return null;
223+
}
224+
}
225+
226+
private static String stripFragment(String pathOrUri) {
227+
int fragmentIndex = pathOrUri.indexOf('#');
228+
return fragmentIndex > 0 ? pathOrUri.substring(0, fragmentIndex) : pathOrUri;
229+
}
230+
184231
/**
185232
* Normalizes a file path or URI string to a proper file URI string. Handles Windows absolute paths, POSIX absolute
186233
* paths, and existing URI strings. Line number fragments (e.g., #L123) are preserved.

com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,30 @@ Not exercised:
192192
#### Key Screenshots
193193
- [ ] **Before created-file Undo** -- The summary bar lists `created-local-file.txt`.
194194
- [ ] **After created-file Undo** -- The summary bar is clear and the file is absent from disk.
195+
196+
---
197+
198+
## 3. Navigate to local files from tool links
199+
200+
### TC-006: Tool result links open local files outside the workspace
201+
202+
**Type:** `Happy Path`
203+
**Priority:** `P0`
204+
205+
#### Preconditions
206+
- The Eclipse workbench is open.
207+
- Copilot Chat is open in Agent mode.
208+
- The local test directory outside the workspace exists and contains `existing-local-file.txt`.
209+
210+
#### Steps
211+
1. Send a prompt that causes Agent mode to reference or edit `existing-local-file.txt` by absolute path.
212+
2. When the tool call appears in the Chat view, click the file path link for `existing-local-file.txt`.
213+
3. Verify Eclipse opens `existing-local-file.txt` in an editor.
214+
215+
#### Expected Result
216+
- File links for paths outside the Eclipse workspace open the local file in an Eclipse editor.
217+
- No error dialog is shown and the Eclipse error log has no local file navigation exception.
218+
219+
#### Key Screenshots
220+
- [ ] **Local file tool link** -- The tool result shows a clickable absolute path outside the workspace.
221+
- [ ] **External local file editor** -- The external local file opens in an Eclipse editor.

com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/FileAnnotationHyperlinkDetector.java

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33

44
package com.microsoft.copilot.eclipse.ui.chat;
55

6+
import java.nio.file.Files;
7+
import java.nio.file.LinkOption;
8+
import java.nio.file.Path;
9+
610
import org.eclipse.core.resources.IFile;
711
import org.eclipse.core.resources.IResource;
812
import org.eclipse.core.resources.ResourcesPlugin;
9-
import org.eclipse.core.runtime.Path;
1013
import org.eclipse.jface.text.IRegion;
1114
import org.eclipse.jface.text.hyperlink.IHyperlink;
1215
import org.eclipse.jface.text.hyperlink.URLHyperlink;
@@ -59,14 +62,27 @@ public void open() {
5962
String urlString = getURLString();
6063
if (urlString.startsWith(LSPEclipseUtils.FILE_URI)) {
6164
IResource targetResource = LSPEclipseUtils.findResourceFor(urlString);
62-
if (targetResource != null && targetResource.getType() == IResource.FILE) {
63-
Location location = new Location();
64-
location.setUri(urlString);
65-
LSPEclipseUtils.openInEditor(location);
65+
if (targetResource != null) {
66+
if (targetResource.getType() == IResource.FILE) {
67+
Location location = new Location();
68+
location.setUri(urlString);
69+
LSPEclipseUtils.openInEditor(location);
70+
return;
71+
}
72+
if (targetResource.getType() == IResource.FOLDER
73+
|| targetResource.getType() == IResource.PROJECT) {
74+
UiUtils.revealInExplorer(targetResource);
75+
return;
76+
}
77+
}
78+
Path localPath = FileUtils.getLocalFilePath(urlString);
79+
if (localPath != null && Files.isRegularFile(localPath, LinkOption.NOFOLLOW_LINKS)) {
80+
UiUtils.openLocalFileInEditor(localPath);
6681
return;
6782
}
6883
} else {
69-
IFile file = ResourcesPlugin.getWorkspace().getRoot().getFile(new Path(urlString));
84+
IFile file = ResourcesPlugin.getWorkspace().getRoot()
85+
.getFile(new org.eclipse.core.runtime.Path(urlString));
7086

7187
if (file.exists()) {
7288
var workbenchWindow = PlatformUI.getWorkbench().getActiveWorkbenchWindow();

com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ private LanguageModelToolResult createFile(String filePath, String content) {
108108
return createWorkspaceFile(file, filePath, content);
109109
}
110110

111-
Path localPath = getLocalFilePath(filePath);
111+
Path localPath = FileUtils.getLocalFilePath(filePath);
112112
if (localPath != null) {
113113
return createLocalFile(localPath, content);
114114
}

com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ private LanguageModelToolResult[] editFile(String filePath, String code) {
146146
return editWorkspaceFile(file, code);
147147
}
148148

149-
Path localPath = getLocalFilePath(filePath);
149+
Path localPath = FileUtils.getLocalFilePath(filePath);
150150
if (localPath != null && Files.isRegularFile(localPath, LinkOption.NOFOLLOW_LINKS)) {
151151
return editLocalFile(localPath, code);
152152
}

com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,9 @@
88
import java.io.InputStream;
99
import java.io.UnsupportedEncodingException;
1010
import java.lang.reflect.InvocationTargetException;
11-
import java.net.URI;
12-
import java.net.URISyntaxException;
1311
import java.nio.charset.StandardCharsets;
1412
import java.nio.file.Files;
1513
import java.nio.file.Path;
16-
import java.nio.file.Paths;
1714
import java.util.Map;
1815
import java.util.concurrent.CompletableFuture;
1916
import java.util.concurrent.ConcurrentHashMap;
@@ -297,24 +294,7 @@ protected Path normalizeLocalPath(Path file) {
297294
return file.toAbsolutePath().normalize();
298295
}
299296

300-
/**
301-
* Resolves an absolute local filesystem path from a path or file URI.
302-
*
303-
* @param filePath the path or URI to resolve
304-
* @return the local filesystem path, or null if the input is not an absolute local path
305-
*/
306-
protected Path getLocalFilePath(String filePath) {
307-
try {
308-
if (filePath.startsWith("file:")) {
309-
return Paths.get(new URI(filePath));
310-
}
311-
Path path = Paths.get(filePath);
312-
return path.isAbsolute() ? path : null;
313-
} catch (IllegalArgumentException | URISyntaxException e) {
314-
CopilotCore.LOGGER.error("Invalid local file path: " + filePath, e);
315-
return null;
316-
}
317-
}
297+
318298

319299
private CompareEditorInput createWorkspaceCompareEditorInput(String comparedContent, IFile file) {
320300
ChangedFile changedFile = ChangedFile.workspace(file);

0 commit comments

Comments
 (0)