diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilder.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilder.java index d43fdced..6d651f6b 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilder.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilder.java @@ -24,6 +24,7 @@ import software.aws.toolkits.eclipse.amazonq.telemetry.metadata.ClientMetadata; import software.aws.toolkits.eclipse.amazonq.telemetry.metadata.PluginClientMetadata; import software.aws.toolkits.eclipse.amazonq.util.AbapUtil; +import software.aws.toolkits.eclipse.amazonq.util.WorkspaceUtils; public class AmazonQLspServerBuilder extends Builder { @@ -60,6 +61,13 @@ private Map getInitializationOptions(final ClientMetadata metada qOptions.put("mcp", true); qOptions.put("pinnedContextEnabled", true); qOptions.put("modelSelection", true); + // Give the server a stable identifier for this Eclipse workspace so chat history is + // keyed per workspace rather than per set-of-open-projects (which changes between + // sessions and causes previously open chat tabs not to be restored). + String workspaceRootPath = WorkspaceUtils.getWorkspaceRootPath(); + if (workspaceRootPath != null) { + qOptions.put("workspaceFilePath", workspaceRootPath); + } awsClientCapabilities.put("q", qOptions); Map window = new HashMap<>(); window.put("showSaveFileDialog", true); diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/util/WorkspaceUtils.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/util/WorkspaceUtils.java index 3332e707..73dffdce 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/util/WorkspaceUtils.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/util/WorkspaceUtils.java @@ -3,10 +3,13 @@ package software.aws.toolkits.eclipse.amazonq.util; +import java.io.IOException; + import org.eclipse.core.resources.IProject; import org.eclipse.core.resources.IResource; import org.eclipse.core.resources.ResourcesPlugin; import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IPath; import org.eclipse.ui.IViewPart; import org.eclipse.ui.IWorkbenchPage; import org.eclipse.ui.IWorkbenchWindow; @@ -19,6 +22,46 @@ public final class WorkspaceUtils { private WorkspaceUtils() { } + /** + * Returns a stable, filesystem-based identifier for the current Eclipse workspace: + * the canonical absolute path of the workspace root (the directory that holds + * {@code .metadata}). + * + *

The language server keys its chat history database on this value when it is provided + * ({@code awsClientCapabilities.q.workspaceFilePath}). Without it the server falls back to + * hashing the open project folders, which changes whenever a project is opened, closed, + * imported or deleted, so a different history file is loaded on the next restart and + * previously open chat tabs are not restored. + * + *

The path is canonicalized so that a symlinked {@code -data} directory, a redundant path + * segment, or a different drive-letter case all resolve to the same identifier. If + * canonicalization fails the literal path is returned, which is still stable for the common + * case. + * + * @return the workspace root path, or {@code null} if it cannot be determined + */ + public static String getWorkspaceRootPath() { + try { + IPath location = ResourcesPlugin.getWorkspace().getRoot().getLocation(); + if (location == null) { + return null; + } + String osPath = location.toOSString(); + if (osPath.isBlank()) { + return null; + } + try { + return location.toFile().getCanonicalPath(); + } catch (IOException e) { + Activator.getLogger().warn("Failed to canonicalize workspace location, using the literal path", e); + return osPath; + } + } catch (Exception e) { + Activator.getLogger().warn("Failed to determine workspace location", e); + return null; + } + } + public static void refreshAllProjects() { IProject[] projects = ResourcesPlugin.getWorkspace().getRoot().getProjects(); for (IProject project : projects) { diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilderTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilderTest.java new file mode 100644 index 00000000..584bc8cb --- /dev/null +++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilderTest.java @@ -0,0 +1,112 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.lsp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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.when; + +import java.util.Map; + +import org.eclipse.lsp4j.InitializeParams; +import org.eclipse.lsp4j.jsonrpc.MessageConsumer; +import org.eclipse.lsp4j.jsonrpc.messages.RequestMessage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import software.aws.toolkits.eclipse.amazonq.telemetry.metadata.ClientMetadata; +import software.aws.toolkits.eclipse.amazonq.telemetry.metadata.PluginClientMetadata; +import software.aws.toolkits.eclipse.amazonq.util.WorkspaceUtils; + +/** + * The server keys its chat history database on + * {@code aws.awsClientCapabilities.q.workspaceFilePath}. The key is a plain string in a nested + * map, so a typo or a dropped entry would compile, pass every other test, and silently restore + * the wrong chat tabs. These tests pin the wire shape. + */ +public class AmazonQLspServerBuilderTest { + + private static final String WORKSPACE_ROOT = "/home/user/eclipse-workspace"; + + private MockedStatic mockedWorkspaceUtils; + private MockedStatic mockedClientMetadata; + + @BeforeEach + public final void setUp() { + mockedWorkspaceUtils = mockStatic(WorkspaceUtils.class); + + ClientMetadata metadata = mock(ClientMetadata.class); + when(metadata.getPluginVersion()).thenReturn("1.0.0"); + when(metadata.getClientId()).thenReturn("client-id"); + when(metadata.getIdeVersion()).thenReturn("2026-09"); + when(metadata.getIdeName()).thenReturn("Eclipse"); + mockedClientMetadata = mockStatic(PluginClientMetadata.class); + mockedClientMetadata.when(PluginClientMetadata::getInstance).thenReturn(metadata); + } + + @AfterEach + public final void tearDown() { + mockedClientMetadata.close(); + mockedWorkspaceUtils.close(); + } + + @Test + public void testSendsWorkspaceFilePathWhenWorkspaceRootIsKnown() { + mockedWorkspaceUtils.when(WorkspaceUtils::getWorkspaceRootPath).thenReturn(WORKSPACE_ROOT); + + Map qCapabilities = qCapabilitiesFromInitialize(); + + assertTrue(qCapabilities.containsKey("workspaceFilePath")); + assertEquals(WORKSPACE_ROOT, qCapabilities.get("workspaceFilePath")); + } + + @Test + public void testOmitsWorkspaceFilePathWhenWorkspaceRootIsUnknown() { + mockedWorkspaceUtils.when(WorkspaceUtils::getWorkspaceRootPath).thenReturn(null); + + Map qCapabilities = qCapabilitiesFromInitialize(); + + // Absent rather than null: the server treats any truthy value as an identifier, so a null + // entry must not be sent. + assertFalse(qCapabilities.containsKey("workspaceFilePath")); + } + + @Test + public void testKeepsOtherQCapabilitiesWhenWorkspaceRootIsUnknown() { + mockedWorkspaceUtils.when(WorkspaceUtils::getWorkspaceRootPath).thenReturn(null); + + Map qCapabilities = qCapabilitiesFromInitialize(); + + assertEquals(true, qCapabilities.get("mcp")); + assertEquals(true, qCapabilities.get("pinnedContextEnabled")); + assertEquals(true, qCapabilities.get("modelSelection")); + assertEquals(true, qCapabilities.get("developerProfiles")); + assertEquals(true, qCapabilities.get("customizationsWithMetadata")); + } + + /** + * Drives an {@code initialize} request through the builder's message consumer and returns the + * {@code aws.awsClientCapabilities.q} map the builder attached to it. + */ + @SuppressWarnings("unchecked") + private Map qCapabilitiesFromInitialize() { + InitializeParams initParams = new InitializeParams(); + RequestMessage message = new RequestMessage(); + message.setMethod("initialize"); + message.setParams(initParams); + + MessageConsumer consumer = new AmazonQLspServerBuilder().wrapMessageConsumer(m -> { }); + consumer.consume(message); + + Map initOptions = (Map) initParams.getInitializationOptions(); + Map aws = (Map) initOptions.get("aws"); + Map clientCapabilities = (Map) aws.get("awsClientCapabilities"); + return (Map) clientCapabilities.get("q"); + } +} diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/util/WorkspaceUtilsTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/util/WorkspaceUtilsTest.java new file mode 100644 index 00000000..d112beaf --- /dev/null +++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/util/WorkspaceUtilsTest.java @@ -0,0 +1,128 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; + +import org.eclipse.core.resources.IWorkspace; +import org.eclipse.core.resources.IWorkspaceRoot; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.IPath; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +import software.aws.toolkits.eclipse.amazonq.plugin.Activator; + +public class WorkspaceUtilsTest { + + private MockedStatic mockedResourcesPlugin; + private MockedStatic mockedActivator; + private IWorkspaceRoot mockRoot; + private LoggingService mockLogger; + + @BeforeEach + public final void setUp() { + mockedResourcesPlugin = mockStatic(ResourcesPlugin.class); + mockedActivator = mockStatic(Activator.class); + mockLogger = mock(LoggingService.class); + mockedActivator.when(Activator::getLogger).thenReturn(mockLogger); + + IWorkspace mockWorkspace = mock(IWorkspace.class); + mockRoot = mock(IWorkspaceRoot.class); + when(mockWorkspace.getRoot()).thenReturn(mockRoot); + mockedResourcesPlugin.when(ResourcesPlugin::getWorkspace).thenReturn(mockWorkspace); + } + + @AfterEach + public final void tearDown() { + mockedActivator.close(); + mockedResourcesPlugin.close(); + } + + @Test + public void testReturnsCanonicalWorkspaceRootPath(final @TempDir Path tempDir) throws IOException { + File workspaceRoot = tempDir.toFile(); + givenWorkspaceLocation(workspaceRoot.getAbsolutePath(), workspaceRoot); + + // Compared against getCanonicalPath() rather than a literal so the assertion holds on + // Windows and on macOS, where the temp directory is itself reached through a symlink. + assertEquals(workspaceRoot.getCanonicalPath(), WorkspaceUtils.getWorkspaceRootPath()); + } + + @Test + public void testResolvesRedundantSegmentsToTheSameIdentifier(final @TempDir Path tempDir) throws IOException { + File workspaceRoot = tempDir.toFile(); + File subDirectory = new File(workspaceRoot, "projects"); + assertTrue(subDirectory.mkdir(), "failed to create fixture directory"); + File indirect = new File(subDirectory, ".."); + + givenWorkspaceLocation(indirect.getPath(), indirect); + + // The whole point of the identifier is stability: two spellings of one directory must not + // produce two different chat history files. + assertEquals(workspaceRoot.getCanonicalPath(), WorkspaceUtils.getWorkspaceRootPath()); + } + + @Test + public void testFallsBackToLiteralPathWhenCanonicalizationFails() throws IOException { + File failingFile = mock(File.class); + when(failingFile.getCanonicalPath()).thenThrow(new IOException("cannot resolve")); + + givenWorkspaceLocation("/home/user/workspace", failingFile); + + assertEquals("/home/user/workspace", WorkspaceUtils.getWorkspaceRootPath()); + verify(mockLogger).warn( + eq("Failed to canonicalize workspace location, using the literal path"), + any(IOException.class)); + } + + @Test + public void testReturnsNullWhenLocationIsNull() { + when(mockRoot.getLocation()).thenReturn(null); + + assertNull(WorkspaceUtils.getWorkspaceRootPath()); + } + + @Test + public void testReturnsNullWhenLocationIsBlankWithoutTouchingTheFilesystem() { + IPath mockPath = mock(IPath.class); + when(mockPath.toOSString()).thenReturn(" "); + when(mockRoot.getLocation()).thenReturn(mockPath); + + assertNull(WorkspaceUtils.getWorkspaceRootPath()); + // A blank path must short-circuit: new File("").getCanonicalPath() resolves to the process + // working directory, which would be a wrong and unstable identifier. + verify(mockPath, never()).toFile(); + } + + @Test + public void testReturnsNullWhenWorkspaceUnavailable() { + mockedResourcesPlugin.when(ResourcesPlugin::getWorkspace).thenThrow(new IllegalStateException("not running")); + + assertNull(WorkspaceUtils.getWorkspaceRootPath()); + } + + private void givenWorkspaceLocation(final String osPath, final File file) { + IPath mockPath = mock(IPath.class); + when(mockPath.toOSString()).thenReturn(osPath); + when(mockPath.toFile()).thenReturn(file); + when(mockRoot.getLocation()).thenReturn(mockPath); + } +}