Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<AmazonQLspServer> {

Expand Down Expand Up @@ -60,6 +61,13 @@ private Map<String, Object> 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<String, Object> window = new HashMap<>();
window.put("showSaveFileDialog", true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}).
*
* <p>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.
*
* <p>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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<WorkspaceUtils> mockedWorkspaceUtils;
private MockedStatic<PluginClientMetadata> 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<String, Object> qCapabilities = qCapabilitiesFromInitialize();

assertTrue(qCapabilities.containsKey("workspaceFilePath"));
assertEquals(WORKSPACE_ROOT, qCapabilities.get("workspaceFilePath"));
}

@Test
public void testOmitsWorkspaceFilePathWhenWorkspaceRootIsUnknown() {
mockedWorkspaceUtils.when(WorkspaceUtils::getWorkspaceRootPath).thenReturn(null);

Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> initOptions = (Map<String, Object>) initParams.getInitializationOptions();
Map<String, Object> aws = (Map<String, Object>) initOptions.get("aws");
Map<String, Object> clientCapabilities = (Map<String, Object>) aws.get("awsClientCapabilities");
return (Map<String, Object>) clientCapabilities.get("q");
}
}
Original file line number Diff line number Diff line change
@@ -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<ResourcesPlugin> mockedResourcesPlugin;
private MockedStatic<Activator> 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);
}
}
Loading