diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 88a892a39..1535d84c0 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -1,12 +1,9 @@ -# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven +# This workflow builds the Eclipse plugin with Maven/Tycho and uploads +# the plugin JAR and p2 update site as downloadable artifacts. +# +# Artifacts are available from the Actions tab on every PR and main push. -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -name: Maven Sanity Build +name: Maven Build on: push: @@ -14,13 +11,16 @@ on: pull_request: branches: [ "main", "feature/*" ] +permissions: + contents: read + jobs: build: - runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Setup Maven Action uses: s4u/setup-maven-action@v1.18.0 with: @@ -28,5 +28,24 @@ jobs: java-version: 17 java-distribution: temurin maven-version: 3.9.8 + - name: Build with Maven run: mvn -B package --file pom.xml + + - name: Upload plugin JAR + if: success() + uses: actions/upload-artifact@v4 + with: + name: amazon-q-eclipse-plugin + path: plugin/target/amazon-q-eclipse-*.jar + if-no-files-found: error + retention-days: 30 + + - name: Upload update site + if: success() + uses: actions/upload-artifact@v4 + with: + name: amazon-q-eclipse-update-site + path: updatesite/target/amazon-q-eclipse-update-site-*.zip + if-no-files-found: error + retention-days: 30 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c5dbeaa6..ac94110ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,6 +57,26 @@ To send us a pull request, please: GitHub provides additional documentation on [forking a repository](https://help.github.com/articles/fork-a-repo/) and [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). +## Updating Plugin Version + +To update the plugin version across all files: + +1. **Prerequisites**: Ensure you have Git Bash (Windows) or Terminal (Mac/Linux) +2. **Run the version script**: + ```bash + ./update-version.sh [new-version] + ``` + Example: `./update-version.sh 2.7.0` + +3. **What gets updated**: + - Root `pom.xml` version + - All child `pom.xml` parent versions + - `plugin/META-INF/MANIFEST.MF` Bundle-Version + - `feature/feature.xml` version + - `updatesite/category.xml` version references + +4. **Build with new version**: `mvn clean install` and `mvn clean package` + ## Debugging/Running Locally To test your changes locally, you can run the plugin from your workspace by importing it into Eclipse. diff --git a/feature/feature.xml b/feature/feature.xml index bfa2216b9..cb00a1b67 100644 --- a/feature/feature.xml +++ b/feature/feature.xml @@ -2,7 +2,7 @@ + version="2.7.5.qualifier"> Amazon Q Developer helps users build faster across the entire software development lifecycle by providing tailored responses and code recommendations that conform to their team's internal libraries, proprietary algorithmic techniques, and enterprise code style. @@ -198,6 +198,6 @@ https://github.com/aws/amazon-q-eclipse/blob/main/attribution.xml id="amazon-q-eclipse" download-size="11000" install-size="0" - version="2.2.1.qualifier" + version="2.7.5.qualifier" unpack="false"/> diff --git a/feature/pom.xml b/feature/pom.xml index 49233e6cf..6fa61ac0f 100644 --- a/feature/pom.xml +++ b/feature/pom.xml @@ -6,7 +6,7 @@ software.aws.toolkits.eclipse amazon-q-eclipse-group - 2.2.1-SNAPSHOT + 2.7.5-SNAPSHOT ../ diff --git a/plugin/META-INF/MANIFEST.MF b/plugin/META-INF/MANIFEST.MF index f24644740..436ead083 100644 --- a/plugin/META-INF/MANIFEST.MF +++ b/plugin/META-INF/MANIFEST.MF @@ -4,7 +4,7 @@ Bundle-Name: Amazon Q for Eclipse Bundle-Provider: Amazon Web Services Bundle-RequiredExecutionEnvironment: JavaSE-17 Bundle-SymbolicName: amazon-q-eclipse;singleton:=true -Bundle-Version: 2.2.1.qualifier +Bundle-Version: 2.7.5.qualifier Automatic-Module-Name: amazon.q.eclipse Bundle-ActivationPolicy: lazy Bundle-Activator: software.aws.toolkits.eclipse.amazonq.plugin.Activator @@ -25,8 +25,8 @@ Require-Bundle: org.eclipse.core.runtime;bundle-version="3.31.0", org.eclipse.mylyn.commons.ui;bundle-version="4.2.0", org.eclipse.jetty.server;bundle-version="12.0.9", org.eclipse.jetty.util;bundle-version="12.0.9", + org.eclipse.jetty.http;bundle-version="12.0.9", org.eclipse.core.net;bundle-version="1.5.400", - org.apache.commons.logging;bundle-version="1.2.0", slf4j.api;bundle-version="2.0.13", org.apache.commons.lang3;bundle-version="3.14.0", org.apache.commons.text;bundle-version="1.10.0", @@ -35,6 +35,7 @@ Require-Bundle: org.eclipse.core.runtime;bundle-version="3.31.0", Bundle-Classpath: ., target/dependency/annotations.jar, target/dependency/apache-client.jar, + target/dependency/arns.jar, target/dependency/auth.jar, target/dependency/aws-core.jar, target/dependency/aws-json-protocol.jar, @@ -42,6 +43,7 @@ Bundle-Classpath: ., target/dependency/checksums.jar, target/dependency/cognitoidentity.jar, target/dependency/commons-codec.jar, + target/dependency/commons-logging.jar, target/dependency/delight-rhino-sandbox.jar, target/dependency/endpoints-spi.jar, target/dependency/http-auth-aws-eventstream.jar, diff --git a/plugin/plugin.xml b/plugin/plugin.xml index 371be6311..f87e519b1 100644 --- a/plugin/plugin.xml +++ b/plugin/plugin.xml @@ -567,8 +567,29 @@ - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugin/pom.xml b/plugin/pom.xml index 4f62832d0..4d3877b6c 100644 --- a/plugin/pom.xml +++ b/plugin/pom.xml @@ -6,7 +6,7 @@ software.aws.toolkits.eclipse amazon-q-eclipse-group - 2.2.1-SNAPSHOT + 2.7.5-SNAPSHOT ../ @@ -91,6 +91,11 @@ regions ${aws.java.sdk.version} + + software.amazon.awssdk + arns + ${aws.java.sdk.version} + software.amazon.awssdk apache-client @@ -189,6 +194,7 @@ com.fasterxml.jackson, com.nimbusds,jakarta.inject, commons-codec, + commons-logging, org.apache.httpcomponents, org.reactivestreams, org.apache.maven, diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/chat/ChatCommunicationManager.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/chat/ChatCommunicationManager.java index 069acb638..703e8f466 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/chat/ChatCommunicationManager.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/chat/ChatCommunicationManager.java @@ -72,8 +72,8 @@ public final class ChatCommunicationManager implements EventObserver lastProcessedTimeMap = new ConcurrentHashMap<>(); private static final int MINIMUM_PARTIAL_RESPONSE_LENGTH = 50; - private static final int MIN_DELAY_BETWEEN_PARTIALS = 500; - private static final int MAX_DELAY_BETWEEN_PARTIALS = 2500; + private static final int MIN_DELAY_BETWEEN_PARTIALS = 250; + private static final int MAX_DELAY_BETWEEN_PARTIALS = 1500; private static final int CHAR_COUNT_FOR_MAX_DELAY = 5000; private final ConcurrentHashMap partialResultLocks = new ConcurrentHashMap<>(); @@ -258,6 +258,52 @@ public void sendMessageToChatServer(final Command command, final ChatMessage mes Activator.getLogger().error("Error processing mcpServerClick: " + e); } break; + case LIST_RULES: + try { + Object listRulesResponse = amazonQLspServer.listRules(message.getData()).get(); + var listRulesCommand = ChatUIInboundCommand.createCommand(ChatUIInboundCommandName.ListRules.getValue(), + listRulesResponse); + Activator.getEventBroker().post(ChatUIInboundCommand.class, listRulesCommand); + } catch (Exception e) { + Activator.getLogger().error("Error processing listRules: " + e); + } + break; + case RULE_CLICK: + try { + Object ruleClickResponse = amazonQLspServer.ruleClick(message.getData()).get(); + var ruleClickCommand = ChatUIInboundCommand.createCommand(ChatUIInboundCommandName.RuleClick.getValue(), + ruleClickResponse); + Activator.getEventBroker().post(ChatUIInboundCommand.class, ruleClickCommand); + } catch (Exception e) { + Activator.getLogger().error("Error processing ruleClick: " + e); + } + break; + case LIST_AVAILABLE_MODELS: + try { + Object listModelsResponse = amazonQLspServer.listAvailableModels(message.getData()).get(); + var listModelsCommand = ChatUIInboundCommand.createCommand(ChatUIInboundCommandName.ListAvailableModels.getValue(), + listModelsResponse); + Activator.getEventBroker().post(ChatUIInboundCommand.class, listModelsCommand); + } catch (Exception e) { + Activator.getLogger().error("Error processing listAvailableModels: " + e); + } + break; + case FILTER_CONTEXT_COMMANDS: + try { + Object filterResponse = amazonQLspServer.filterContextCommands(message.getData()).get(); + var filterCommand = ChatUIInboundCommand.createCommand(ChatUIInboundCommandName.FilterContextCommands.getValue(), + filterResponse); + Activator.getEventBroker().post(ChatUIInboundCommand.class, filterCommand); + } catch (Exception e) { + Activator.getLogger().error("Error processing filterContextCommands: " + e); + } + break; + case PINNED_CONTEXT_ADD: + amazonQLspServer.pinnedContextAdd(message.getData()); + break; + case PINNED_CONTEXT_REMOVE: + amazonQLspServer.pinnedContextRemove(message.getData()); + break; default: throw new AmazonQPluginException("Unexpected command received from Chat UI: " + command.toString()); } @@ -602,7 +648,10 @@ public void handlePartialResultProgressNotification(final ProgressParams params) // send partial response to UI if not cancelled in the interim if (Boolean.FALSE.equals(finalResultProcessed.get(token))) { sendMessageToChatUI(new ChatUIInboundCommand(command, tabId, partialChatResult, true, null)); - lastProcessedTimeMap.put(tabId, currentTime); + // only update timestamp for rate-limited messages (string body without additional messages) + if (!hasAdditionalMessages && body instanceof String) { + lastProcessedTimeMap.put(tabId, currentTime); + } } } } diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/chat/models/ChatUIInboundCommandName.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/chat/models/ChatUIInboundCommandName.java index a9fa44eda..6e03572ea 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/chat/models/ChatUIInboundCommandName.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/chat/models/ChatUIInboundCommandName.java @@ -15,7 +15,12 @@ public enum ChatUIInboundCommandName { GenericCommand("genericCommand"), ChatOptionsUpdate("aws/chat/chatOptionsUpdate"), ListMcpServers("aws/chat/listMcpServers"), - McpServerClick("aws/chat/mcpServerClick"); + McpServerClick("aws/chat/mcpServerClick"), + ListRules("aws/chat/listRules"), + RuleClick("aws/chat/ruleClick"), + ListAvailableModels("aws/chat/listAvailableModels"), + FilterContextCommands("aws/chat/filterContextCommands"), + SendPinnedContext("aws/chat/sendPinnedContext"); private final String value; diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/configuration/profiles/QDeveloperProfileUtil.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/configuration/profiles/QDeveloperProfileUtil.java index 566121293..41a8552da 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/configuration/profiles/QDeveloperProfileUtil.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/configuration/profiles/QDeveloperProfileUtil.java @@ -17,7 +17,6 @@ import org.eclipse.swt.widgets.Display; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import software.amazon.awssdk.utils.StringUtils; @@ -61,51 +60,36 @@ private QDeveloperProfileUtil() { // prevent initialization .ofNullable(Activator.getPluginStore().get(ViewConstants.Q_DEVELOPER_PROFILE_SELECTION_KEY)) .map(json -> { try { - if (isValidSerializedProfile(json)) { - return deserializeProfile(json); + Activator.getLogger().info("Found cached developer profile during init, attempting to validate and deserialize"); + QDeveloperProfile profile = deserializeProfile(json); + if (isValidCachedProfile(profile)) { + Activator.getLogger().info("Loaded cached developer profile: " + profile.getName()); + return profile; } else { - Activator.getLogger().error("Cached profile has invalid format"); + Activator.getLogger().error("Cached profile has invalid data"); } } catch (final JsonProcessingException e) { - Activator.getLogger().error("Failed to process cached profile", e); + Activator.getLogger().error("Failed to deserialize cached profile", e); } return null; }).orElse(null); } catch (Exception e) { - Activator.getLogger().error("Failed to deserialize developer profile", e); + Activator.getLogger().error("Failed to load developer profile during init", e); } profileSelectionTask = new CompletableFuture<>(); profiles = new ArrayList<>(); } - private boolean isValidSerializedProfile(final String profile) throws JsonProcessingException { - JsonNode node = OBJECT_MAPPER.readTree(profile); - return node.has("arn") && isValidArn(node.get("arn").asText()) && node.has("name") - && StringUtils.isNotBlank(node.get("name").asText()) && node.has("accountId") - && isValidAccountId(node.get("accountId").asText()) && node.has("region") - && node.get("identityDetails").has("region") - && isValidRegion(node.get("identityDetails").get("region").asText()); - } private QDeveloperProfile deserializeProfile(final String json) throws JsonProcessingException { - QDeveloperProfile deserializedProfile = OBJECT_MAPPER.readValue(json, QDeveloperProfile.class); - - if (!isValidProfile(deserializedProfile)) { - throw new JsonProcessingException("Cached profile has invalid data") { - private static final long serialVersionUID = 1L; - }; - } - return deserializedProfile; + return OBJECT_MAPPER.readValue(json, QDeveloperProfile.class); } private String serializeProfile(final QDeveloperProfile developerProfile) throws JsonProcessingException { - if (!isValidProfile(developerProfile)) { - throw new JsonProcessingException("Developer profile has invalid data") { - private static final long serialVersionUID = 1L; - }; + if (developerProfile == null) { + throw new IllegalArgumentException("Unable to serialize null profile"); } - return OBJECT_MAPPER.writeValueAsString(developerProfile); } @@ -118,6 +102,7 @@ public void initialize() { Activator.getLoginService().logout(); return null; }).thenAccept(result -> { + Activator.getLogger().info("Fetched developer profiles, validating current customization"); CustomizationUtil.validateCurrentCustomization(); }); savedDeveloperProfile = null; @@ -135,6 +120,7 @@ public synchronized CompletableFuture> queryForDeveloper private synchronized CompletableFuture> queryForDeveloperProfilesFuture( final boolean tryApplyCachedProfile, final boolean applyProfileUnconditionally) { + Activator.getLogger().info("Fetching Q developer profiles..."); return Activator.getLspProvider().getAmazonQServer() .thenCompose(server -> { GetConfigurationFromServerParams params = new GetConfigurationFromServerParams( @@ -142,7 +128,11 @@ private synchronized CompletableFuture> queryForDevelope CompletableFuture> response = server .getConfigurationFromServer(params); return response; - }).thenApply(this::processConfigurations).exceptionally(throwable -> { + }).thenApply(configurations -> { + var profiles = processConfigurations(configurations); + Activator.getLogger().info("Fetched " + profiles.size() + " Q developer profiles"); + return profiles; + }).exceptionally(throwable -> { Activator.getLogger().error("Error occurred while fetching the list of Q Developer Profile: ", throwable); throw new AmazonQPluginException(throwable); @@ -155,7 +145,7 @@ public synchronized List queryForDeveloperProfiles(final bool try { return queryForDeveloperProfilesFuture(tryApplyCachedProfile, false).get(); } catch (InterruptedException e) { - Activator.getLogger().error("Interrupted when fetching profile: ", e); + Activator.getLogger().error("Error occurred when fetching profile: ", e); } return new ArrayList<>(); @@ -169,22 +159,24 @@ public synchronized CompletableFuture getProfileSelectionTaskFuture() { return profileSelectionTask; } - private boolean isValidProfile(final QDeveloperProfile profile) { - return profile != null && StringUtils.isNotBlank(profile.getName()) && isValidAccountId(profile.getAccountId()) - && isValidArn(profile.getArn()) && isValidRegion(profile.getRegion()); + private boolean isValidFetchedProfile(final QDeveloperProfile profile) { + return profile != null; + } + + private boolean isValidCachedProfile(final QDeveloperProfile profile) { + return profile != null && isValidAccountId(profile.getAccountId()) && isValidArn(profile.getArn()) && isValidRegion(profile.getRegion()); } private boolean isValidAccountId(final String accountId) { - return accountId != null && accountId.matches("^\\d{12}$"); + return StringUtils.isNotBlank(accountId); } private boolean isValidArn(final String arn) { - return arn != null && arn.matches("^arn:aws:codewhisperer:[a-z]{2}-[a-z]+-\\d:\\d{12}:profile/[A-Z0-9]+$"); + return StringUtils.isNotBlank(arn); } private boolean isValidRegion(final String region) { - return region != null && region - .matches("^[a-z]{2}-(central|north|south|east|west|northeast|southeast|northwest|southwest)-(\\d)$"); + return StringUtils.isNotBlank(region); } private List handleSelectedProfile(final List profiles, @@ -224,6 +216,7 @@ private void setProfiles(final List profiles) { private boolean handleSingleOrNoProfile(final List profiles, final boolean tryApplyCachedProfile, final boolean applyProfileUnconditionally) { if (!profiles.isEmpty() && tryApplyCachedProfile) { + Activator.getLogger().info("Found single developer profile, auto-selecting"); setDeveloperProfile(profiles.get(0), true, applyProfileUnconditionally); return true; } @@ -240,7 +233,10 @@ private boolean handleMultipleProfiles(final List profiles, }); if (isProfileSelected && tryApplyCachedProfile) { + Activator.getLogger().info("Using cached profile: " + selectedDeveloperProfile.getName()); setDeveloperProfile(selectedDeveloperProfile, true, applyProfileUnconditionally); + } else if (!isProfileSelected) { + Activator.getLogger().warn("Cached profile not found in available profiles, user selection required"); } } return isProfileSelected; @@ -250,7 +246,8 @@ private List processConfigurations( final LspServerConfigurations configurations) { return Optional.ofNullable(configurations).map( config -> { - return config.getConfigurations().stream().filter(this::isValidProfile) + // we assume backend would return a valid profile and do not any further validations + return config.getConfigurations().stream().filter(this::isValidFetchedProfile) .collect(Collectors.toList()); }) .orElse(Collections.emptyList()); @@ -263,9 +260,13 @@ public List getDeveloperProfiles() { } try { - return queryForDeveloperProfiles(false); + List fetchedProfiles = queryForDeveloperProfiles(false); + if (fetchedProfiles == null || fetchedProfiles.isEmpty()) { + Activator.getLogger().warn("No developer profiles available"); + } + return fetchedProfiles; } catch (Exception e) { - Activator.getLogger().error("Interupted while fetching profiles: " + e); + Activator.getLogger().error("Failed to fetch profiles: ", e); } return null; @@ -283,6 +284,7 @@ private CompletableFuture setDeveloperProfile(final QDeveloperProfile deve return CompletableFuture.completedFuture(null); } + Activator.getLogger().info("Setting developer profile: " + developerProfile.getName()); selectedDeveloperProfile = developerProfile; saveSelectedProfile(); @@ -340,6 +342,8 @@ private void saveSelectedProfile() { } } catch (final JsonProcessingException e) { Activator.getLogger().error("Failed to cache Q developer profile"); + } catch (Exception e) { + Activator.getLogger().error("Unexpected error while caching Q developer profile", e); } } @@ -347,15 +351,17 @@ public boolean isProfileSelectionRequired() { if (profiles == null || profiles.isEmpty()) { try { queryForDeveloperProfiles(false); + if (profiles == null || profiles.isEmpty()) { + Activator.getLogger().info("No Q developer profiles found"); + } else if (profiles.size() == 1) { + handleSingleOrNoProfile(profiles, true, false); + } } catch (Exception e) { - Activator.getLogger().error("Interrupted when fetching profile: ", e); - } - - if (profiles.size() == 1) { - handleSingleOrNoProfile(profiles, true, false); + Activator.getLogger().error("Error occurred when fetching profile: ", e); + return false; } } - return profiles.size() > 1; + return profiles != null && profiles.size() > 1; } public QDeveloperProfile getSelectedProfile() { diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/inlineChat/InlineChatUIManager.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/inlineChat/InlineChatUIManager.java index 2f24ebebc..df2d7660b 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/inlineChat/InlineChatUIManager.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/inlineChat/InlineChatUIManager.java @@ -3,6 +3,8 @@ package software.aws.toolkits.eclipse.amazonq.inlineChat; +import java.util.Arrays; +import java.util.HashSet; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicReference; @@ -27,13 +29,12 @@ import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Listener; import org.eclipse.swt.widgets.Text; import software.aws.toolkits.eclipse.amazonq.chat.models.CursorState; import software.aws.toolkits.eclipse.amazonq.plugin.Activator; import software.aws.toolkits.eclipse.amazonq.util.Constants; -import software.aws.toolkits.eclipse.amazonq.util.PluginPlatform; -import software.aws.toolkits.eclipse.amazonq.util.PluginUtils; import software.aws.toolkits.eclipse.amazonq.util.ToolkitNotification; public final class InlineChatUIManager { @@ -52,6 +53,7 @@ public final class InlineChatUIManager { private final String decidingMessage = "Accept (Enter) | Reject (Esc)"; private boolean isDarkTheme; private int latestOffset; + private Listener paintListenerRef = null; private InlineChatUIManager() { // Prevent instantiation @@ -134,53 +136,41 @@ protected Control createDialogArea(final Composite parent) { var composite = (Composite) super.createDialogArea(parent); composite.setLayout(new GridLayout(1, false)); - inputField = new Text(composite, SWT.SEARCH | SWT.BORDER | SWT.SINGLE); - if (PluginUtils.getPlatform() == PluginPlatform.WINDOWS) { - Display.getDefault().asyncExec(() -> { - inputField.setForeground(Display.getDefault().getSystemColor(SWT.COLOR_GRAY)); - inputField.setText(inputPromptMessage); - }); + inputField = new Text(composite, SWT.BORDER | SWT.MULTI); + Display.getDefault().asyncExec(() -> { + inputField.setForeground(Display.getDefault().getSystemColor(SWT.COLOR_GRAY)); + inputField.setText(inputPromptMessage); + }); - inputField.addKeyListener(new KeyAdapter() { - @Override - public void keyPressed(final KeyEvent e) { - // If this is the first character being typed - boolean backspace = (e.keyCode == SWT.DEL || e.keyCode == SWT.BS); - if (inputField.getText().equals(inputPromptMessage)) { - if (!backspace) { - inputField.setText(""); - inputField.setForeground(isDarkTheme - ? Display.getDefault().getSystemColor(SWT.COLOR_WHITE) - : Display.getDefault().getSystemColor(SWT.COLOR_BLACK)); - } - e.doit = !backspace; - } else if (backspace && inputField.getText().length() <= 1) { - inputField.setText(inputPromptMessage); - inputField.setForeground(Display.getDefault().getSystemColor(SWT.COLOR_GRAY)); + inputField.addKeyListener(new KeyAdapter() { + @Override + public void keyPressed(final KeyEvent e) { + // If this is the first character being typed + boolean backspace = (e.keyCode == SWT.DEL || e.keyCode == SWT.BS); + if (inputField.getText().equals(inputPromptMessage)) { + if (!backspace) { + inputField.setText(""); + inputField.setForeground(isDarkTheme + ? Display.getDefault().getSystemColor(SWT.COLOR_WHITE) + : Display.getDefault().getSystemColor(SWT.COLOR_BLACK)); } + e.doit = !backspace; + } else if (backspace && inputField.getText().length() <= 1) { + inputField.setText(inputPromptMessage); + inputField.setForeground(Display.getDefault().getSystemColor(SWT.COLOR_GRAY)); } - }); - } else { - inputField.setMessage(inputPromptMessage); - } + } + }); GridData gridData = new GridData(GridData.FILL_HORIZONTAL); gridData.widthHint = 350; + gridData.heightHint = 25; inputField.setLayoutData(gridData); - // Enforce maximum character count that can be entered into the input - inputField.addVerifyListener(e -> { - String currentText = inputField.getText(); - String newText = currentText.substring(0, e.start) + e.text + currentText.substring(e.end); - if (newText.length() > maxInputLength) { - e.doit = false; // Prevent the input - } - }); - inputField.addKeyListener(new KeyAdapter() { @Override public void keyPressed(final KeyEvent e) { - if (e.character == SWT.CR || e.character == SWT.LF) { + if (e.keyCode == SWT.CR || e.keyCode == SWT.LF) { // Gather inputs and send back to controller var userInput = inputField.getText(); if (userInputIsValid(userInput)) { @@ -235,7 +225,7 @@ private void showPrompt(final String promptText) { latestOffset = task.getSelectionOffset(); } currentPaintListener = createPaintListenerPrompt(widget, latestOffset, promptText, isDarkTheme); - widget.addPaintListener(currentPaintListener); + addPaintListenerAndCapture(widget, currentPaintListener); widget.redraw(); } catch (Exception e) { Activator.getLogger().error("Failed to create paint listener: " + e.getMessage(), e); @@ -243,6 +233,43 @@ private void showPrompt(final String promptText) { }); } + private void addPaintListenerAndCapture(final StyledText widget, final PaintListener paintListener) { + var listenersBefore = new HashSet<>(Arrays.asList(widget.getListeners(SWT.Paint))); + widget.addPaintListener(paintListener); + var listenersAfter = widget.getListeners(SWT.Paint); + for (var listener : listenersAfter) { + if (!listenersBefore.contains(listener)) { + if (isAdtPaintListener(listener)) { + paintListenerRef = listener; + } + break; + } + } + } + + /** + * ADT Viewers wrap the paint listener into an internal delegate(PaintListenerDelegate). + * which needs to be captured to effectively remove it later + * @param listener + * @return listener ref + */ + private boolean isAdtPaintListener(final Listener listener) { + try { + // Use reflection to check if this wraps a PaintListenerDelegate + if (listener.getClass().getName().contains("TypedListener")) { + var eventListenerField = listener.getClass().getDeclaredField("eventListener"); + eventListenerField.setAccessible(true); + Object eventListener = eventListenerField.get(listener); + if (eventListener != null && eventListener.getClass().getName().contains("PaintListenerDelegate")) { + return true; + } + } + } catch (Exception e) { + // Ignore reflection errors + } + return false; + } + public void updatePromptPosition(final SessionState state) { try { int offset = ((ITextViewerExtension5) viewer).modelOffset2WidgetOffset(task.getSelectionOffset()); @@ -340,9 +367,17 @@ private void removeCurrentPaintListener() { return; } try { - if (viewer.getTextWidget() != null && !viewer.getTextWidget().isDisposed() && currentPaintListener != null) { - viewer.getTextWidget().removePaintListener(currentPaintListener); - viewer.getTextWidget().redraw(); + var widget = viewer.getTextWidget(); + if (widget != null && !widget.isDisposed() && currentPaintListener != null) { + // remove adt specific paint listener if present + if (paintListenerRef != null) { + widget.removeListener(SWT.Paint, paintListenerRef); + paintListenerRef = null; + } + if (currentPaintListener != null) { + widget.removePaintListener(currentPaintListener); + } + widget.redraw(); currentPaintListener = null; } } catch (Exception e) { @@ -364,7 +399,7 @@ private int calculateIndentOffset(final StyledText widget, final int currentOffs } private boolean userInputIsValid(final String input) { - return input != null && input.length() >= 2 && input.length() < maxInputLength; + return input != null && input.length() >= 2; } /** diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClient.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClient.java index d50d5bc77..54ae81373 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClient.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClient.java @@ -61,4 +61,6 @@ public interface AmazonQLspClient extends LanguageClient { @JsonNotification("aws/didCreateDirectory") void didCreateDirectory(Object params); + @JsonNotification("aws/chat/sendPinnedContext") + void sendPinnedContext(Object params); } diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClientImpl.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClientImpl.java index 8f44c782c..dbeda359d 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClientImpl.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClientImpl.java @@ -7,6 +7,7 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -17,7 +18,9 @@ import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.commons.lang3.StringUtils; import org.eclipse.core.filesystem.EFS; import org.eclipse.core.filesystem.IFileStore; import org.eclipse.core.resources.IWorkspace; @@ -64,6 +67,7 @@ import software.aws.toolkits.eclipse.amazonq.chat.ChatAsyncResultManager; import software.aws.toolkits.eclipse.amazonq.chat.ChatCommunicationManager; import software.aws.toolkits.eclipse.amazonq.chat.models.ChatUIInboundCommand; +import software.aws.toolkits.eclipse.amazonq.chat.models.ChatUIInboundCommandName; import software.aws.toolkits.eclipse.amazonq.chat.models.GetSerializedChatParams; import software.aws.toolkits.eclipse.amazonq.chat.models.GetSerializedChatResult; import software.aws.toolkits.eclipse.amazonq.chat.models.SerializedChatResult; @@ -82,8 +86,10 @@ import software.aws.toolkits.eclipse.amazonq.lsp.model.SsoProfileData; import software.aws.toolkits.eclipse.amazonq.lsp.model.TelemetryEvent; import software.aws.toolkits.eclipse.amazonq.plugin.Activator; +import software.aws.toolkits.eclipse.amazonq.util.QEclipseEditorUtils; import software.aws.toolkits.eclipse.amazonq.preferences.AmazonQPreferencePage; import software.aws.toolkits.eclipse.amazonq.telemetry.service.DefaultTelemetryService; +import software.aws.toolkits.eclipse.amazonq.util.AbapUtil; import software.aws.toolkits.eclipse.amazonq.util.Constants; import software.aws.toolkits.eclipse.amazonq.util.ObjectMapperFactory; import software.aws.toolkits.eclipse.amazonq.util.ThemeDetector; @@ -128,14 +134,6 @@ public final CompletableFuture> configuration(final ConfigurationPa qConfig.put(Constants.LSP_CUSTOMIZATION_CONFIGURATION_KEY, Objects.nonNull(storedCustomization) ? storedCustomization.getArn() : null); qConfig.put(Constants.LSP_ENABLE_TELEMETRY_EVENTS_CONFIGURATION_KEY, false); qConfig.put(Constants.LSP_OPT_OUT_TELEMETRY_CONFIGURATION_KEY, !DefaultTelemetryService.telemetryEnabled()); - Map projectContextConfig = new HashMap<>(); - boolean indexingSetting = Activator.getDefault().getPreferenceStore().getBoolean(AmazonQPreferencePage.WORKSPACE_INDEX); - boolean gpuIndexingSetting = Activator.getDefault().getPreferenceStore().getBoolean(AmazonQPreferencePage.USE_GPU_FOR_INDEXING); - int indexThreadsSetting = Activator.getDefault().getPreferenceStore().getInt(AmazonQPreferencePage.INDEX_WORKER_THREADS); - projectContextConfig.put(Constants.LSP_INDEXING_CONFIGURATION_KEY, indexingSetting); - projectContextConfig.put(Constants.LSP_GPU_INDEXING_CONFIGURATION_KEY, gpuIndexingSetting); - projectContextConfig.put(Constants.LSP_INDEX_THREADS_CONFIGURATION_KEY, indexThreadsSetting); - qConfig.put(Constants.LSP_PROJECT_CONTEXT_CONFIGURATION_KEY, projectContextConfig); output.add(qConfig); Activator.getLspProvider().activate(AmazonQLspServer.class); } else if (item.getSection().equals(Constants.LSP_CW_CONFIGURATION_KEY)) { @@ -223,15 +221,10 @@ public final CompletableFuture showDocument(final ShowDocume } else { Display.getDefault().syncExec(() -> { try { - if (!isUriInWorkspace(uri)) { - Activator.getLogger().error("Attempted to open file outside workspace: " + uri); - success[0] = false; - } else { - IWorkbenchPage page = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); - IFileStore fileStore = EFS.getLocalFileSystem().getStore(new URI(uri)); - IDE.openEditorOnFileStore(page, fileStore); - success[0] = true; - } + IWorkbenchPage page = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); + IFileStore fileStore = EFS.getLocalFileSystem().getStore(new URI(uri)); + IDE.openEditorOnFileStore(page, fileStore); + success[0] = true; } catch (Exception e) { Activator.getLogger().error("Error in UI thread while opening URI: " + uri, e); success[0] = false; @@ -538,11 +531,19 @@ public final void didCopyFile(final Object params) { @Override public final void didWriteFile(final Object params) { + var path = extractFilePathFromParams(params); + if (AbapUtil.isAbapFile(path)) { + AbapUtil.updateAdtServer(path); + } refreshProjects(); } @Override public final void didAppendFile(final Object params) { + var path = extractFilePathFromParams(params); + if (AbapUtil.isAbapFile(path)) { + AbapUtil.updateAdtServer(path); + } refreshProjects(); } @@ -558,6 +559,7 @@ public final void didCreateDirectory(final Object params) { private void refreshProjects() { WorkspaceUtils.refreshAllProjects(); + WorkspaceUtils.refreshAdtViews(); } private boolean isUriInWorkspace(final String uri) { @@ -575,4 +577,91 @@ private boolean isUriInWorkspace(final String uri) { return false; } } + + private String extractFilePathFromParams(final Object params) { + if (params instanceof Map) { + var map = (Map) params; + Object path = map.get("path"); + return path != null ? path.toString() : null; + } + return null; + } + + @Override + public final void sendPinnedContext(final Object params) { + Object updatedParams = params; + Optional fileUri = getActiveFileUri(); + if (fileUri.isPresent()) { + Map textDocument = Map.of("uri", fileUri.get()); + if (params instanceof Map) { + @SuppressWarnings("unchecked") + Map paramsMap = new HashMap<>((Map) params); + paramsMap.put("textDocument", textDocument); + updatedParams = paramsMap; + } else { + updatedParams = Map.of("params", params, "textDocument", textDocument); + } + } + + var sendPinnedContextCommand = ChatUIInboundCommand.createCommand(ChatUIInboundCommandName.SendPinnedContext.getValue(), updatedParams); + Activator.getEventBroker().post(ChatUIInboundCommand.class, sendPinnedContextCommand); + } + + private Optional getActiveFileUri() { + AtomicReference> fileUri = new AtomicReference<>(); + Display.getDefault().syncExec(() -> { + try { + fileUri.set(getActiveEditorRelativePath()); + } catch (Exception e) { + Activator.getLogger().error("Error getting active file URI", e); + fileUri.set(Optional.empty()); + } + }); + return fileUri.get(); + } + + private Optional getActiveEditorRelativePath() { + var activeEditor = QEclipseEditorUtils.getActiveTextEditor(); + if (activeEditor == null) { + return Optional.empty(); + } + return QEclipseEditorUtils.getOpenFileUri(activeEditor.getEditorInput()) + .map(this::getRelativePath); + } + + private String getRelativePath(final String absoluteUri) { + try { + if (StringUtils.isBlank(absoluteUri)) { + return absoluteUri; + } + + var uri = new URI(absoluteUri); + var activeFilePath = new File(uri).getCanonicalPath(); + + // Get workspace root path + var workspace = ResourcesPlugin.getWorkspace(); + var workspacePath = workspace.getRoot().getLocation(); + if (workspacePath == null) { + return activeFilePath; + } + + var workspaceRoot = workspacePath.toFile().getCanonicalPath(); + if (StringUtils.isBlank(workspaceRoot)) { + return activeFilePath; + } + + if (StringUtils.startsWithIgnoreCase(activeFilePath, workspaceRoot)) { + var workspaceRootPath = Paths.get(workspaceRoot); + var activeFilePathObj = Paths.get(activeFilePath); + var relativePath = workspaceRootPath.relativize(activeFilePathObj).normalize(); + return relativePath.toString().replace('\\', '/'); + } + + // Not in workspace, return absolute path + return activeFilePath; + } catch (Exception e) { + Activator.getLogger().error("Error occurred when attempting to determine relative path for: " + absoluteUri, e); + return absoluteUri; + } + } } diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServer.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServer.java index 06c0572d7..df8ca06c3 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServer.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServer.java @@ -129,4 +129,25 @@ CompletableFuture> getConfi @JsonRequest("aws/chat/mcpServerClick") CompletableFuture mcpServerClick(Object params); + + @JsonRequest("aws/chat/listRules") + CompletableFuture listRules(Object params); + + @JsonRequest("aws/chat/ruleClick") + CompletableFuture ruleClick(Object params); + + @JsonRequest("aws/chat/listAvailableModels") + CompletableFuture listAvailableModels(Object params); + + @JsonRequest("aws/chat/filterContextCommands") + CompletableFuture filterContextCommands(Object params); + + @JsonNotification("aws/chat/pinnedContextAdd") + void pinnedContextAdd(Object params); + + @JsonNotification("aws/chat/pinnedContextRemove") + void pinnedContextRemove(Object params); + + @JsonNotification("aws/chat/activeEditorChanged") + void activeEditorChanged(Object params); } 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 51d7f74ea..6b657c856 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilder.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilder.java @@ -14,6 +14,7 @@ import org.eclipse.lsp4j.jsonrpc.messages.Message; import org.eclipse.lsp4j.jsonrpc.messages.RequestMessage; import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage; +import org.apache.commons.lang3.StringUtils; import com.google.gson.ToNumberPolicy; @@ -22,6 +23,7 @@ import software.aws.toolkits.eclipse.amazonq.plugin.Activator; 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; public class AmazonQLspServerBuilder extends Builder { @@ -56,6 +58,8 @@ private Map getInitializationOptions(final ClientMetadata metada qOptions.put("developerProfiles", true); qOptions.put("customizationsWithMetadata", true); qOptions.put("mcp", true); + qOptions.put("pinnedContextEnabled", true); + qOptions.put("modelSelection", true); awsClientCapabilities.put("q", qOptions); Map window = new HashMap<>(); window.put("showSaveFileDialog", true); @@ -65,11 +69,29 @@ private Map getInitializationOptions(final ClientMetadata metada return initOptions; } + private void sanitizeWorkspaceFoldersForAbap(final InitializeParams initParams) { + try { + if (initParams.getWorkspaceFolders() != null) { + initParams.getWorkspaceFolders().forEach(folder -> { + if (StringUtils.isNotBlank(folder.getUri()) && folder.getUri().startsWith(AbapUtil.SEMANTIC_FS_SCHEME)) { + String convertedUri = AbapUtil.convertSemanticUriToPath(folder.getUri()); + if (StringUtils.isNotBlank(convertedUri)) { + folder.setUri(convertedUri); + } + } + }); + } + } catch (Exception e) { + Activator.getLogger().error("Error sanitizing workspace folders for ABAP", e); + } + } + @Override protected final MessageConsumer wrapMessageConsumer(final MessageConsumer consumer) { return super.wrapMessageConsumer((final Message message) -> { if (message instanceof RequestMessage && ((RequestMessage) message).getMethod().equals("initialize")) { InitializeParams initParams = (InitializeParams) ((RequestMessage) message).getParams(); + sanitizeWorkspaceFoldersForAbap(initParams); ClientMetadata metadata = PluginClientMetadata.getInstance(); initParams.setClientInfo(new ClientInfo(USER_AGENT_CLIENT_NAME, metadata.getPluginVersion())); initParams.setInitializationOptions(getInitializationOptions(metadata)); diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/LspStartupActivity.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/LspStartupActivity.java index 8e9d0cbac..3a43ed19a 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/LspStartupActivity.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/LspStartupActivity.java @@ -18,6 +18,7 @@ import org.eclipse.ui.PlatformUI; import software.aws.toolkits.eclipse.amazonq.broker.events.QDeveloperProfileState; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationPollingService; import software.aws.toolkits.eclipse.amazonq.plugin.Activator; import software.aws.toolkits.eclipse.amazonq.providers.browser.AmazonQBrowserProvider; import software.aws.toolkits.eclipse.amazonq.telemetry.ToolkitTelemetryProvider; @@ -26,6 +27,7 @@ import software.aws.toolkits.eclipse.amazonq.util.AutoTriggerPartListener; import software.aws.toolkits.eclipse.amazonq.util.AutoTriggerTopLevelListener; import software.aws.toolkits.eclipse.amazonq.util.Constants; +import software.aws.toolkits.eclipse.amazonq.util.KiroSunsetNotification; import software.aws.toolkits.eclipse.amazonq.util.ThreadingUtils; import software.aws.toolkits.eclipse.amazonq.util.ToolkitNotification; import software.aws.toolkits.eclipse.amazonq.util.UpdateUtils; @@ -80,10 +82,23 @@ private void schedulePostStartupJobs() { Display.getDefault().asyncExec(() -> launchWebview()); } Display.getDefault().asyncExec(() -> attachAutoTriggerListenersIfApplicable()); + Display.getDefault().asyncExec(() -> showKiroSunsetNotification()); checkForUpdates(); + NotificationPollingService.getInstance().start(); }); } + private void showKiroSunsetNotification() { + if (Activator.getPluginStore().get(Constants.KIRO_SUNSET_NOTIFICATION_DISMISSED_KEY) != null) { + return; + } + AbstractNotificationPopup notification = new KiroSunsetNotification(Display.getCurrent(), + Constants.KIRO_SUNSET_NOTIFICATION_TITLE, + Constants.KIRO_SUNSET_NOTIFICATION_BODY, + () -> Activator.getPluginStore().put(Constants.KIRO_SUNSET_NOTIFICATION_DISMISSED_KEY, "true")); + notification.open(); + } + private void checkForUpdates() { Job updateCheckJob = new Job("Check for updates") { @Override diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/connection/QLspConnectionProvider.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/connection/QLspConnectionProvider.java index 7ede76445..496deb525 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/connection/QLspConnectionProvider.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/connection/QLspConnectionProvider.java @@ -7,6 +7,7 @@ import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStream; +import java.nio.file.Files; import java.nio.file.Paths; import java.time.Instant; import java.util.ArrayList; @@ -58,13 +59,14 @@ public QLspConnectionProvider() throws IOException { @Override protected final void addEnvironmentVariables(final Map env) { String httpsProxyUrl = ProxyUtil.getHttpsProxyUrl(); - String caCertPreference = Activator.getDefault().getPreferenceStore().getString(AmazonQPreferencePage.CA_CERT); + String caCertPath = getCaCert(); + if (!StringUtils.isEmpty(httpsProxyUrl)) { env.put("HTTPS_PROXY", httpsProxyUrl); } - if (!StringUtils.isEmpty(caCertPreference)) { - env.put("NODE_EXTRA_CA_CERTS", caCertPreference); - env.put("AWS_CA_BUNDLE", caCertPreference); + if (!StringUtils.isEmpty(caCertPath)) { + env.put("NODE_EXTRA_CA_CERTS", caCertPath); + env.put("AWS_CA_BUNDLE", caCertPath); } if (ArchitectureUtils.isWindowsArm()) { env.put("DISABLE_INDEXING_LIBRARY", "true"); @@ -78,6 +80,27 @@ protected final void addEnvironmentVariables(final Map env) { } } + private String getCaCert() { + String caCertPreference = Activator.getDefault().getPreferenceStore().getString(AmazonQPreferencePage.CA_CERT); + if (!StringUtils.isEmpty(caCertPreference)) { + Activator.getLogger().info("Using user-defined CA cert: " + caCertPreference); + return caCertPreference; + } + try { + String pemContent = ProxyUtil.getCertificatesAsPem(); + if (StringUtils.isEmpty(pemContent)) { + return null; + } + var tempPath = Files.createTempFile("eclipse-q-extra-ca", ".pem"); + Activator.getLogger().info("Injecting IDE trusted certificates from " + tempPath + " into NODE_EXTRA_CA_CERTS"); + Files.write(tempPath, pemContent.getBytes()); + return tempPath.toString(); + } catch (Exception e) { + Activator.getLogger().warn("Could not create temp CA cert file", e); + return null; + } + } + private boolean needsPatchEnvVariables() { return PluginUtils.getPlatform().equals(PluginPlatform.MAC); } diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/editor/ActiveEditorChangeListener.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/editor/ActiveEditorChangeListener.java new file mode 100644 index 000000000..418bce90a --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/editor/ActiveEditorChangeListener.java @@ -0,0 +1,136 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.lsp.editor; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ScheduledFuture; + +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.IPartListener2; + +import org.eclipse.ui.IWorkbenchPartReference; +import org.eclipse.ui.IWorkbenchWindow; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.texteditor.ITextEditor; + +import software.aws.toolkits.eclipse.amazonq.plugin.Activator; +import software.aws.toolkits.eclipse.amazonq.util.AbapUtil; +import software.aws.toolkits.eclipse.amazonq.util.QEclipseEditorUtils; +import software.aws.toolkits.eclipse.amazonq.util.ThreadingUtils; + +public final class ActiveEditorChangeListener implements IPartListener2 { + private static ActiveEditorChangeListener instance; + private static final long DEBOUNCE_DELAY_MS = 100L; + private ScheduledFuture debounceTask; + private IWorkbenchWindow registeredWindow; + + private ActiveEditorChangeListener() { + } + + public static ActiveEditorChangeListener getInstance() { + if (instance == null) { + instance = new ActiveEditorChangeListener(); + } + return instance; + } + + public void initialize() { + Display.getDefault().asyncExec(() -> { + try { + registeredWindow = PlatformUI.getWorkbench().getActiveWorkbenchWindow(); + if (registeredWindow != null) { + registeredWindow.getPartService().addPartListener(this); + } + } catch (Exception e) { + Activator.getLogger().error("Failed to initialize ActiveEditorChangeListener", e); + } + }); + } + + public void stop() { + if (debounceTask != null) { + debounceTask.cancel(true); + } + try { + if (registeredWindow != null) { + registeredWindow.getPartService().removePartListener(this); + } + } catch (Exception e) { + Activator.getLogger().error("Error stopping ActiveEditorChangeListener", e); + } + } + + private boolean isAdtEditor(final Object part) { + return part instanceof IEditorPart && AbapUtil.isAdtEditor(part.getClass().getName()); + } + + @Override + public void partActivated(final IWorkbenchPartReference partRef) { + var editor = partRef.getPart(false); + if (editor instanceof ITextEditor || isAdtEditor(editor)) { + handleEditorChange(editor); + } + } + + @Override + public void partClosed(final IWorkbenchPartReference partRef) { + var editor = partRef.getPart(false); + if (editor instanceof ITextEditor || isAdtEditor(editor)) { + handleEditorChange(null); + } + } + + private void handleEditorChange(final Object editor) { + // Cancel any pending notification + if (debounceTask != null) { + debounceTask.cancel(false); + } + + // Schedule a new notification after the debounce period + debounceTask = (ScheduledFuture) ThreadingUtils.scheduleAsyncTaskWithDelay(() -> { + Display.getDefault().syncExec(() -> { + try { + Map params = createActiveEditorParams(editor); + var lspServer = Activator.getLspProvider().getAmazonQServer().get(); + lspServer.activeEditorChanged(params); + } catch (Exception e) { + Activator.getLogger().error("Failed to send active editor changed notification", e); + } + }); + }, DEBOUNCE_DELAY_MS); + } + + private Map createActiveEditorParams(final Object editor) { + Map params = new HashMap<>(); + if (editor != null) { + Optional fileUri = Optional.empty(); + if (editor instanceof ITextEditor te) { + fileUri = QEclipseEditorUtils.getOpenFileUri(te.getEditorInput()); + } else if (isAdtEditor(editor)) { + fileUri = QEclipseEditorUtils.getOpenFileUri(((IEditorPart) editor).getEditorInput()); + } + if (fileUri.isPresent()) { + Map textDocument = new HashMap<>(); + textDocument.put("uri", fileUri.get()); + params.put("textDocument", textDocument); + if (editor instanceof ITextEditor textEditor) { + QEclipseEditorUtils.getSelectionRange(textEditor).ifPresent(range -> { + Map cursorState = new HashMap<>(); + cursorState.put("range", range); + params.put("cursorState", cursorState); + }); + } else if (isAdtEditor(editor)) { + params.put("cursorState", null); + } + } + } else { + params.put("textDocument", null); + params.put("cursorState", null); + } + return params; + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/manager/LspConstants.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/manager/LspConstants.java index 0b1725668..5aad492b8 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/manager/LspConstants.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/manager/LspConstants.java @@ -32,7 +32,7 @@ private LspConstants() { private static VersionRange createVersionRange() { try { - return VersionRange.createFromVersionSpec("[1.0.0, 1.50.0)"); + return VersionRange.createFromVersionSpec("[1.0.0, 2.0.0)"); } catch (InvalidVersionSpecificationException e) { throw new AmazonQPluginException("Failed to parse LSP supported version range", e); } diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/model/InlineCompletionParams.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/model/InlineCompletionParams.java index bfc6c7520..c99d83b6f 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/model/InlineCompletionParams.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/model/InlineCompletionParams.java @@ -3,11 +3,14 @@ package software.aws.toolkits.eclipse.amazonq.lsp.model; +import java.util.List; + import org.eclipse.lsp4j.TextDocumentPositionAndWorkDoneProgressParams; public class InlineCompletionParams extends TextDocumentPositionAndWorkDoneProgressParams { private InlineCompletionContext context; + private List openTabFilepaths; public final InlineCompletionContext getContext() { return context; @@ -17,4 +20,12 @@ public final void setContext(final InlineCompletionContext context) { this.context = context; } + public final List getOpenTabFilepaths() { + return openTabFilepaths; + } + + public final void setOpenTabFilepaths(final List openTabFilepaths) { + this.openTabFilepaths = openTabFilepaths; + } + } diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/AmazonQNotificationPopup.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/AmazonQNotificationPopup.java new file mode 100644 index 000000000..c54dfbbac --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/AmazonQNotificationPopup.java @@ -0,0 +1,141 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.util.List; + +import org.eclipse.jface.resource.ImageDescriptor; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Label; +import org.eclipse.ui.ISharedImages; +import org.eclipse.ui.PlatformUI; + +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationSeverity; +import software.aws.toolkits.eclipse.amazonq.plugin.Activator; +import software.aws.toolkits.eclipse.amazonq.util.ToolkitNotification; + +/** + * A toast notification that renders a severity icon, wrapped description, and N action buttons built from a hosted + * notification's actions. INFO/WARNING keep the base auto-close timer; CRITICAL overrides {@link #scheduleAutoClose()} + * to a no-op so it persists until the user dismisses it, ensuring critical alerts reach the user. + */ +public final class AmazonQNotificationPopup extends ToolkitNotification { + + /** A rendered action button: a label plus the handler to run when clicked. */ + public record NotificationAction(String label, Runnable onClick) { } + + private final String description; + // Mylyn's default auto-close is 8s, which is too short to read a multi-line known-issue message. + private static final long TRANSIENT_DELAY_CLOSE_MS = 20_000L; + + private final NotificationSeverity severity; + private final boolean persistent; + private final List actions; + + public AmazonQNotificationPopup(final Display display, final String title, final String description, + final NotificationSeverity severity, final List actions) { + super(display, title, description); + this.description = description; + this.severity = severity; + this.persistent = severity == NotificationSeverity.CRITICAL; + this.actions = actions == null ? List.of() : List.copyOf(actions); + // CRITICAL persists until dismissed (delayClose = 0 => scheduleAutoClose is a no-op); others stay readable. + final long delayClose = persistent ? 0L : TRANSIENT_DELAY_CLOSE_MS; + setDelayClose(delayClose); + Activator.getLogger().info("AmazonQNotificationPopup created: severity=" + severity + + " persistent=" + persistent + " delayCloseMs=" + delayClose); + } + + @Override + protected void scheduleAutoClose() { + // Belt-and-suspenders: never schedule an auto-close for a persistent (CRITICAL) notification. + if (!persistent) { + super.scheduleAutoClose(); + } + } + + @Override + protected void createContentArea(final Composite parent) { + final Composite container = new Composite(parent, SWT.NONE); + container.setLayout(new GridLayout(2, false)); + container.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + + final Label iconLabel = new Label(container, SWT.NONE); + final Image icon = createSeverityImage(severity); + if (icon != null) { + iconLabel.setImage(icon); + // close() is final in the base class, so dispose the icon via a listener instead of overriding close(). + iconLabel.addDisposeListener(e -> { + if (!icon.isDisposed()) { + icon.dispose(); + } + }); + } + iconLabel.setLayoutData(new GridData(SWT.BEGINNING, SWT.BEGINNING, false, false)); + + final Label messageLabel = new Label(container, SWT.WRAP); + messageLabel.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + messageLabel.setText(description != null ? description : ""); + + if (!actions.isEmpty()) { + createActionButtons(parent); + } + } + + private void createActionButtons(final Composite parent) { + final Composite buttonRow = new Composite(parent, SWT.NONE); + final GridLayout layout = new GridLayout(actions.size(), false); + layout.marginWidth = 0; + layout.marginHeight = 0; + buttonRow.setLayout(layout); + buttonRow.setLayoutData(new GridData(SWT.END, SWT.CENTER, true, false)); + + for (final NotificationAction action : actions) { + final Button button = new Button(buttonRow, SWT.PUSH); + button.setText(action.label()); + button.setLayoutData(new GridData(SWT.END, SWT.CENTER, false, false)); + button.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(final SelectionEvent e) { + action.onClick().run(); + close(); + } + }); + } + } + + private static Image createSeverityImage(final NotificationSeverity severity) { + try { + final ISharedImages sharedImages = PlatformUI.getWorkbench().getSharedImages(); + final ImageDescriptor descriptor = sharedImages.getImageDescriptor(iconKey(severity)); + if (descriptor == null) { + return null; + } + // createImage(false) returns null (rather than throwing) if the image can't be loaded. + return descriptor.createImage(false); + } catch (Exception e) { + return null; + } + } + + static String iconKey(final NotificationSeverity severity) { + switch (severity) { + case CRITICAL: + return ISharedImages.IMG_OBJS_ERROR_TSK; + case WARNING: + return ISharedImages.IMG_OBJS_WARN_TSK; + case INFO: + default: + return ISharedImages.IMG_OBJS_INFO_TSK; + } + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/DismissedNotification.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/DismissedNotification.java new file mode 100644 index 000000000..4e2d9cf08 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/DismissedNotification.java @@ -0,0 +1,36 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +/** A dismissed notification id plus the epoch-millis timestamp it was dismissed (for retention cleanup). Gson-friendly. */ +public final class DismissedNotification { + + private String id; + private long dismissedAtEpochMs; + + public DismissedNotification() { + // no-arg constructor for Gson + } + + public DismissedNotification(final String id, final long dismissedAtEpochMs) { + this.id = id; + this.dismissedAtEpochMs = dismissedAtEpochMs; + } + + public String getId() { + return id; + } + + public void setId(final String id) { + this.id = id; + } + + public long getDismissedAtEpochMs() { + return dismissedAtEpochMs; + } + + public void setDismissedAtEpochMs(final long dismissedAtEpochMs) { + this.dismissedAtEpochMs = dismissedAtEpochMs; + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/FeatureAuthDetails.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/FeatureAuthDetails.java new file mode 100644 index 000000000..f77c792fd --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/FeatureAuthDetails.java @@ -0,0 +1,8 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +/** Snapshot of a feature's auth/connection state, used by the rules engine's {@code authx} matching. */ +public record FeatureAuthDetails(String connectionType, String region, String connectionState) { +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationActionFactory.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationActionFactory.java new file mode 100644 index 000000000..997ef2447 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationActionFactory.java @@ -0,0 +1,73 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.swt.widgets.Display; + +import software.aws.toolkits.eclipse.amazonq.notifications.AmazonQNotificationPopup.NotificationAction; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationFollowupAction; +import software.aws.toolkits.eclipse.amazonq.util.Constants; +import software.aws.toolkits.eclipse.amazonq.util.PluginUtils; + +/** + * Builds the rendered action buttons for a notification from its hosted {@code actions[]}, ports the JetBrains mapping: + * {@code ShowUrl} (case-sensitive) only supplies the URL for the always-present "More" button; {@code UpdateExtension} + * and {@code OpenChangelog} open pages; unknown types are ignored. + */ +public final class NotificationActionFactory { + + private static final String SHOW_URL = "ShowUrl"; + private static final String UPDATE_EXTENSION = "UpdateExtension"; + private static final String OPEN_CHANGELOG = "OpenChangelog"; + + private NotificationActionFactory() { + // prevent instantiation + } + + public static List createActions(final String notificationId, + final List followupActions, final String title, final String description) { + final List result = new ArrayList<>(); + String moreUrl = null; + + if (followupActions != null) { + for (final NotificationFollowupAction action : followupActions) { + final String type = action.type(); + if (SHOW_URL.equals(type)) { + if (action.content() != null && action.content().enUs() != null) { + moreUrl = action.content().enUs().url(); + } + } else if (UPDATE_EXTENSION.equals(type)) { + result.add(new NotificationAction("Update", () -> { + NotificationTelemetryProvider.emitInvokeAction(notificationId, UPDATE_EXTENSION); + PluginUtils.openWebpage(Constants.AMAZON_Q_UPDATE_SITE_URL); + })); + } else if (OPEN_CHANGELOG.equals(type)) { + result.add(new NotificationAction("View changelog", () -> { + NotificationTelemetryProvider.emitInvokeAction(notificationId, OPEN_CHANGELOG); + PluginUtils.openWebpage(Constants.AMAZON_Q_CHANGELOG_URL); + })); + } + } + } + + final String capturedUrl = moreUrl; + result.add(new NotificationAction("More", () -> { + NotificationTelemetryProvider.emitInvokeAction(notificationId, "More"); + showMoreDialog(title, description, capturedUrl); + })); + return result; + } + + private static void showMoreDialog(final String title, final String description, final String url) { + if (url != null && !url.isBlank()) { + PluginUtils.handleExternalLinkClick(url); + } else { + MessageDialog.openInformation(Display.getDefault().getActiveShell(), title, description); + } + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationConstants.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationConstants.java new file mode 100644 index 000000000..91b592c91 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationConstants.java @@ -0,0 +1,28 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +/** Endpoint, cache, and storage-key constants for the hosted-file notifications feature. */ +public final class NotificationConstants { + + /** Production hosted-file endpoint for Eclipse notifications (schema 2.x combined). */ + public static final String NOTIFICATIONS_ENDPOINT = + "https://idetoolkits-hostedfiles.amazonaws.com/Notifications/Eclipse/combined/2.x.json"; + + /** Subdirectory (under the plugin state dir) that holds the cached notifications file. */ + public static final String NOTIFICATIONS_SUBDIRECTORY = "notifications"; + + /** Filename of the cached notifications payload. */ + public static final String NOTIFICATIONS_CACHE_FILENAME = "notifications.json"; + + /** PluginStore key under which dismissed-notification state is persisted. */ + public static final String DISMISSAL_STORAGE_KEY = "qNotificationDismissals"; + + /** Environment variable that overrides the endpoint (for local dev/testing). */ + public static final String NOTIFICATIONS_ENDPOINT_ENV = "AMAZONQ_NOTIFICATIONS_ENDPOINT"; + + private NotificationConstants() { + // prevent instantiation + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationData.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationData.java new file mode 100644 index 000000000..a06c7553b --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationData.java @@ -0,0 +1,110 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.util.List; +import java.util.Locale; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A single hosted notification (schema 2.x). Ported from the JetBrains notification model so payloads + * stay compatible across IDEs. Unknown JSON keys are ignored (the shared mapper disables + * FAIL_ON_UNKNOWN_PROPERTIES), so a missing optional block deserializes to {@code null}. + */ +public record NotificationData( + String id, + NotificationSchedule schedule, + String severity, + NotificationDisplayCondition condition, + NotificationContent content, + List actions) { + + /** How often the notification is shown. */ + public enum NotificationScheduleType { + /** Shown once per IDE session (on the first poll). */ + STARTUP, + /** Shown on every poll until dismissed. */ + EMERGENCY; + + /** + * Maps the raw JSON value case-insensitively: only {@code "startup"} yields + * {@link #STARTUP}; anything else (including typos and {@code null}) yields {@link #EMERGENCY}. + */ + @JsonCreator + public static NotificationScheduleType fromString(final String value) { + return value != null && "startup".equals(value.toLowerCase(Locale.ROOT)) + ? STARTUP + : EMERGENCY; + } + } + + /** Notification severity; drives the toast style. */ + public enum NotificationSeverity { + INFO, + WARNING, + CRITICAL; + + /** Maps the JSON value case-insensitively; any unrecognized or {@code null} value yields {@link #INFO}. */ + public static NotificationSeverity fromString(final String value) { + if (value == null) { + return INFO; + } + if ("Critical".equalsIgnoreCase(value)) { + return CRITICAL; + } + if ("Warning".equalsIgnoreCase(value)) { + return WARNING; + } + return INFO; + } + } + + /** Wrapper around the schedule type as it appears in JSON: {@code { "type": "Startup" }}. */ + public record NotificationSchedule(NotificationScheduleType type) { } + + /** + * Display conditions. All present blocks must match (logical AND); a {@code null} block is skipped. + * Note {@code extension} is a singular-named array, matching the JetBrains field the rules engine reads. + */ + public record NotificationDisplayCondition( + ComputeType compute, + SystemType os, + SystemType ide, + List extension, + List authx) { } + + /** Compute-environment condition. */ + public record ComputeType(NotificationExpression type, NotificationExpression architecture) { } + + /** OS or IDE condition (type + version). */ + public record SystemType(NotificationExpression type, NotificationExpression version) { } + + /** Installed-extension condition, matched by id + optional version expression. */ + public record ExtensionType(String id, NotificationExpression version) { } + + /** Authentication/connection condition for a feature (for example {@code "q"}). */ + public record AuthxType( + String feature, + NotificationExpression type, + NotificationExpression region, + NotificationExpression connectionState, + NotificationExpression ssoScopes) { } + + /** Localized notification content. Only the {@code en-US} locale is consumed. */ + public record NotificationContent(@JsonProperty("en-US") LocalizedContent enUs) { } + + /** Title/description for a single locale. */ + public record LocalizedContent(String title, String description) { } + + /** A follow-up action (button) on the notification. */ + public record NotificationFollowupAction(String type, NotificationFollowupActionContent content) { } + + /** Localized action content. */ + public record NotificationFollowupActionContent(@JsonProperty("en-US") LocalizedAction enUs) { } + + /** Title and optional URL for a single locale's action. */ + public record LocalizedAction(String title, String url) { } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationDismissalConfiguration.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationDismissalConfiguration.java new file mode 100644 index 000000000..4b61eb6a5 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationDismissalConfiguration.java @@ -0,0 +1,29 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.util.ArrayList; +import java.util.List; + +/** + * Concrete wrapper persisted via {@code PluginStore.putObject}/{@code getObject}. A concrete class (rather than a raw + * generic collection) is required because {@code getObject(key, Class)} deserializes with Gson reflecting into the + * declared field type, which correctly recovers the {@link DismissedNotification} element type. + */ +public final class NotificationDismissalConfiguration { + + private List dismissedNotifications = new ArrayList<>(); + + public NotificationDismissalConfiguration() { + // no-arg constructor for Gson + } + + public List getDismissedNotifications() { + return dismissedNotifications; + } + + public void setDismissedNotifications(final List dismissedNotifications) { + this.dismissedNotifications = dismissedNotifications; + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationDismissalStore.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationDismissalStore.java new file mode 100644 index 000000000..b65674eb8 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationDismissalStore.java @@ -0,0 +1,74 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import software.aws.toolkits.eclipse.amazonq.configuration.PluginStore; +import software.aws.toolkits.eclipse.amazonq.plugin.Activator; + +/** + * Persists dismissed-notification ids (with a 60-day retention) via {@link PluginStore}. All mutations funnel through + * synchronized methods so a load-modify-write (which spans two PluginStore calls) cannot lose entries under concurrency. + */ +public final class NotificationDismissalStore { + + private static final Duration RETENTION = Duration.ofDays(60); + + private final PluginStore pluginStore; + + public NotificationDismissalStore() { + this(Activator.getPluginStore()); + } + + public NotificationDismissalStore(final PluginStore pluginStore) { + this.pluginStore = pluginStore; + } + + public synchronized boolean isDismissed(final String id) { + // Anchor equals() on the argument so a persisted entry with a null id cannot NPE and abort processing. + return loadAndClean().getDismissedNotifications().stream().anyMatch(d -> id.equals(d.getId())); + } + + public synchronized void dismiss(final String id) { + final NotificationDismissalConfiguration config = loadAndClean(); + final List dismissed = config.getDismissedNotifications(); + if (dismissed.stream().anyMatch(d -> id.equals(d.getId()))) { + return; + } + dismissed.add(new DismissedNotification(id, Instant.now().toEpochMilli())); + pluginStore.putObject(NotificationConstants.DISMISSAL_STORAGE_KEY, config); + } + + private NotificationDismissalConfiguration loadAndClean() { + NotificationDismissalConfiguration config; + boolean corrupt = false; + try { + config = pluginStore.getObject(NotificationConstants.DISMISSAL_STORAGE_KEY, + NotificationDismissalConfiguration.class); + } catch (Exception e) { + Activator.getLogger().warn("Corrupt notification dismissal state; resetting", e); + config = null; + corrupt = true; + } + if (config == null || config.getDismissedNotifications() == null) { + final NotificationDismissalConfiguration fresh = new NotificationDismissalConfiguration(); + // If the stored bytes were corrupt, persist the reset once so we stop re-parsing (and re-warning on) + // the bad value every poll. + if (corrupt) { + pluginStore.putObject(NotificationConstants.DISMISSAL_STORAGE_KEY, fresh); + } + return fresh; + } + final Instant cutoff = Instant.now().minus(RETENTION); + final boolean removedAny = config.getDismissedNotifications() + .removeIf(d -> Instant.ofEpochMilli(d.getDismissedAtEpochMs()).isBefore(cutoff)); + if (removedAny) { + pluginStore.putObject(NotificationConstants.DISMISSAL_STORAGE_KEY, config); + } + return config; + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationExpression.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationExpression.java new file mode 100644 index 000000000..97b8130dc --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationExpression.java @@ -0,0 +1,51 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.util.List; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +/** + * A notification display-condition expression, encoded in the hosted JSON as a single-key wrapper + * object (for example { "==": "1.0" } or { "and": [ ... ] }). The operator + * is the wrapper key; the value is a bare string, an array of strings, or nested expressions. + * Ported from the JetBrains schema-2.x notification model so the rules engine can match 1:1. + */ +@JsonDeserialize(using = NotificationExpressionDeserializer.class) +public sealed interface NotificationExpression { + + /** Matches when the actual value equals the given value (==). */ + record ComparisonCondition(String value) implements NotificationExpression { } + + /** Matches when the actual value does not equal the given value (!=). */ + record NotEqualsCondition(String value) implements NotificationExpression { } + + /** Matches when the actual value is greater than the given value (>). */ + record GreaterThanCondition(String value) implements NotificationExpression { } + + /** Matches when the actual value is greater than or equal to the given value (>=). */ + record GreaterThanOrEqualsCondition(String value) implements NotificationExpression { } + + /** Matches when the actual value is less than the given value (<). */ + record LessThanCondition(String value) implements NotificationExpression { } + + /** Matches when the actual value is less than or equal to the given value (<=). */ + record LessThanOrEqualsCondition(String value) implements NotificationExpression { } + + /** Matches when the actual value is contained in the given list (anyOf). */ + record AnyOfCondition(List value) implements NotificationExpression { } + + /** Matches when the actual value is not contained in the given list (noneOf). */ + record NoneOfCondition(List value) implements NotificationExpression { } + + /** Matches when every nested expression matches (and). */ + record AndCondition(List expectedValueList) implements NotificationExpression { } + + /** Matches when any nested expression matches (or). */ + record OrCondition(List expectedValueList) implements NotificationExpression { } + + /** Matches when the nested expression does not match (not). */ + record NotCondition(NotificationExpression expectedValue) implements NotificationExpression { } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationExpressionDeserializer.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationExpressionDeserializer.java new file mode 100644 index 000000000..7535b7a76 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationExpressionDeserializer.java @@ -0,0 +1,92 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Deserializes a {@link NotificationExpression} from its single-key operator-wrapper object form, + * for example { ">=": "1.0" }, { "anyOf": ["a", "b"] }, or + * { "and": [ { ">=": "1.0" }, { "<": "2.0" } ] }. The and, + * or, and not operators nest recursively. + */ +public final class NotificationExpressionDeserializer extends JsonDeserializer { + + @Override + public NotificationExpression deserialize(final JsonParser parser, final DeserializationContext ctxt) throws IOException { + JsonNode node = parser.getCodec().readTree(parser); + if (node == null || !node.isObject() || node.size() != 1) { + throw new JsonMappingException(parser, "Notification expression must be a single-key operator object"); + } + + Map.Entry entry = node.fields().next(); + String operator = entry.getKey(); + JsonNode value = entry.getValue(); + + switch (operator) { + case "==": + return new NotificationExpression.ComparisonCondition(value.asText()); + case "!=": + return new NotificationExpression.NotEqualsCondition(value.asText()); + case ">": + return new NotificationExpression.GreaterThanCondition(value.asText()); + case ">=": + return new NotificationExpression.GreaterThanOrEqualsCondition(value.asText()); + case "<": + return new NotificationExpression.LessThanCondition(value.asText()); + case "<=": + return new NotificationExpression.LessThanOrEqualsCondition(value.asText()); + case "anyOf": + return new NotificationExpression.AnyOfCondition(toStringList(parser, value, operator)); + case "noneOf": + return new NotificationExpression.NoneOfCondition(toStringList(parser, value, operator)); + case "and": + return new NotificationExpression.AndCondition(toExpressionList(parser, value, operator)); + case "or": + return new NotificationExpression.OrCondition(toExpressionList(parser, value, operator)); + case "not": + return new NotificationExpression.NotCondition(toExpression(parser, value)); + default: + throw new JsonMappingException(parser, "Unknown notification expression operator: " + operator); + } + } + + private List toStringList(final JsonParser parser, final JsonNode value, final String operator) throws JsonMappingException { + if (!value.isArray()) { + throw new JsonMappingException(parser, operator + " must contain an array of values"); + } + List values = new ArrayList<>(); + for (JsonNode element : value) { + values.add(element.asText()); + } + return values; + } + + private List toExpressionList(final JsonParser parser, final JsonNode value, final String operator) + throws IOException { + if (!value.isArray()) { + throw new JsonMappingException(parser, operator + " must contain an array of expressions"); + } + List expressions = new ArrayList<>(); + Iterator elements = value.elements(); + while (elements.hasNext()) { + expressions.add(toExpression(parser, elements.next())); + } + return expressions; + } + + private NotificationExpression toExpression(final JsonParser parser, final JsonNode value) throws IOException { + return parser.getCodec().treeToValue(value, NotificationExpression.class); + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationPollingService.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationPollingService.java new file mode 100644 index 000000000..853f3f766 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationPollingService.java @@ -0,0 +1,179 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.time.Duration; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledFuture; +import java.util.function.BiFunction; +import java.util.function.Supplier; + +import software.aws.toolkits.eclipse.amazonq.plugin.Activator; +import software.aws.toolkits.eclipse.amazonq.util.ThreadingUtils; + +/** + * App-level singleton that polls the notifications endpoint every 10 minutes on the shared worker pool, + * self-rescheduling after each poll. The poll body is total (fetch never throws) and the re-arm happens in a + * {@code finally}. + * + *

Lifecycle: {@link #start()} is called once at startup; {@link #shutdown()} must be called early in + * {@code Activator.stop()} to permanently cancel polling during teardown. {@link #onEnabledPreferenceChanged()} lets + * the notifications kill-switch pause/resume polling within a session without an IDE restart. + * + *

The scheduler and collaborators are injectable via a package-private constructor so unit tests can drive + * start/stop/reschedule deterministically without SWT, the network, or a real thread pool. + */ +public final class NotificationPollingService { + + private static final NotificationPollingService INSTANCE = new NotificationPollingService(); + private static final long POLL_INTERVAL_MS = Duration.ofMinutes(10).toMillis(); + + /** Abstracts the scheduler so tests can inject a deterministic one; returns a cancellable handle or null. */ + interface PollScheduler { + ScheduledFuture schedule(Runnable task, long delayMs); + } + + private final Supplier enabledSupplier; + private final Supplier devBuildSupplier; + private final Supplier endpointOverrideSupplier; + private final Supplier fetcherSupplier; + private final Supplier processorSupplier; + private final PollScheduler scheduler; + + private volatile boolean shutdown; + private volatile boolean running; + private volatile ScheduledFuture scheduledPoll; + private volatile NotificationsFetcher fetcher; + private volatile ProcessNotifications processor; + + private NotificationPollingService() { + this( + NotificationPreferences::isNotificationsEnabled, + SystemDetailsCollector::isDevBuild, + NotificationPreferences::hasEndpointOverride, + () -> new NotificationsFetcher(NotificationPreferences.resolveEndpoint()), + () -> new ProcessNotifications(new NotificationDismissalStore()), + defaultScheduler()); + } + + // Package-private for tests. + NotificationPollingService(final Supplier enabledSupplier, final Supplier devBuildSupplier, + final Supplier endpointOverrideSupplier, final Supplier fetcherSupplier, + final Supplier processorSupplier, final PollScheduler scheduler) { + this.enabledSupplier = enabledSupplier; + this.devBuildSupplier = devBuildSupplier; + this.endpointOverrideSupplier = endpointOverrideSupplier; + this.fetcherSupplier = fetcherSupplier; + this.processorSupplier = processorSupplier; + this.scheduler = scheduler; + } + + private static PollScheduler defaultScheduler() { + final BiFunction> sched = + (task, delay) -> (ScheduledFuture) ThreadingUtils.scheduleAsyncTaskWithDelay(task, delay); + return (task, delayMs) -> { + try { + return sched.apply(task, delayMs); + } catch (RejectedExecutionException e) { + Activator.getLogger().info("Notifications polling stopped (worker pool shutting down)"); + return null; + } + }; + } + + public static NotificationPollingService getInstance() { + return INSTANCE; + } + + /** Starts polling once per app lifetime; no-op if disabled, a dev build without override, or already running. */ + public synchronized void start() { + if (shutdown || running) { + return; + } + if (!enabledSupplier.get()) { + return; + } + // Development/unreleased builds must not receive production notifications. Allow an explicit endpoint + // override (preference or env var) so local/demo testing against a test endpoint still works. + if (devBuildSupplier.get() && !endpointOverrideSupplier.get()) { + Activator.getLogger().info("Notifications polling skipped: development build with no endpoint override"); + return; + } + this.fetcher = fetcherSupplier.get(); + this.processor = processorSupplier.get(); + // Mark running BEFORE scheduling: the first poll is scheduled with delay 0, so on a real thread pool the + // poll can execute before this method returns. pollOnce() early-returns unless running==true, so if we set + // the flag after scheduling the very first poll can silently no-op (no fetch, no toast, no reschedule). + // running is volatile, so the poll thread observes this write. Roll it back if the scheduler rejects. + running = true; + // Schedule the first poll instead of running it inline so start() never blocks its caller (the shared + // startup worker thread) on network I/O. + final ScheduledFuture scheduled = scheduler.schedule(this::pollOnce, 0L); + if (scheduled == null) { + // The worker pool rejected the task (e.g. shutting down). Reset running so a later re-enable can retry + // rather than latching into a started-but-never-scheduled state. + running = false; + return; + } + scheduledPoll = scheduled; + } + + void pollOnce() { + if (shutdown || !running || !enabledSupplier.get()) { + return; + } + try { + fetcher.fetch().ifPresent(processor::process); + } catch (Throwable t) { + Activator.getLogger().warn("Notifications poll failed", t); + NotificationTelemetryProvider.emitPollFailure("Failed to poll for notifications"); + } finally { + reschedule(); + } + } + + private synchronized void reschedule() { + if (shutdown || !running || !enabledSupplier.get()) { + return; + } + // reschedule() and shutdown() are both synchronized on this monitor, so shutdown cannot interleave here; + // the entry guard above plus shutdown()'s cancelPending() are sufficient to stop post-teardown polls. + scheduledPoll = scheduler.schedule(this::pollOnce, POLL_INTERVAL_MS); + } + + /** + * Reacts to a change in the notifications kill-switch preference: starts polling if it was turned on, or pauses + * (cancels the pending poll) if it was turned off. Unlike {@link #shutdown()}, this is reversible in-session. + */ + public synchronized void onEnabledPreferenceChanged() { + if (shutdown) { + return; + } + if (enabledSupplier.get()) { + start(); + } else { + pause(); + } + } + + private synchronized void pause() { + running = false; + cancelPending(); + } + + /** Permanently cancels polling for teardown; not resumable. */ + public synchronized void shutdown() { + shutdown = true; + running = false; + cancelPending(); + } + + private void cancelPending() { + final ScheduledFuture current = scheduledPoll; + if (current != null) { + current.cancel(false); + scheduledPoll = null; + } + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationPreferences.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationPreferences.java new file mode 100644 index 000000000..399f70fb9 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationPreferences.java @@ -0,0 +1,48 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import software.aws.toolkits.eclipse.amazonq.plugin.Activator; +import software.aws.toolkits.eclipse.amazonq.preferences.AmazonQPreferencePage; + +/** Reads the notifications kill-switch preference and resolves the endpoint (preference > env > prod default). */ +public final class NotificationPreferences { + + private NotificationPreferences() { + // prevent instantiation + } + + /** Whether the notifications feature is enabled (kill-switch); defaults to {@code true}. */ + public static boolean isNotificationsEnabled() { + return Activator.getDefault().getPreferenceStore().getBoolean(AmazonQPreferencePage.NOTIFICATIONS_OPT_IN); + } + + /** Resolves the endpoint URL. Precedence: preference override -> environment variable -> production default. */ + public static String resolveEndpoint() { + final String pref = Activator.getDefault().getPreferenceStore() + .getString(AmazonQPreferencePage.NOTIFICATIONS_ENDPOINT_OVERRIDE); + if (pref != null && !pref.isBlank()) { + return pref; + } + final String env = System.getenv(NotificationConstants.NOTIFICATIONS_ENDPOINT_ENV); + if (env != null && !env.isBlank()) { + return env; + } + return NotificationConstants.NOTIFICATIONS_ENDPOINT; + } + + /** + * Whether an explicit endpoint override (preference or environment variable) is set. Used to let a + * development/PDE build opt in to polling a test endpoint, which is otherwise suppressed on dev builds. + */ + public static boolean hasEndpointOverride() { + final String pref = Activator.getDefault().getPreferenceStore() + .getString(AmazonQPreferencePage.NOTIFICATIONS_ENDPOINT_OVERRIDE); + if (pref != null && !pref.isBlank()) { + return true; + } + final String env = System.getenv(NotificationConstants.NOTIFICATIONS_ENDPOINT_ENV); + return env != null && !env.isBlank(); + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationTelemetryProvider.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationTelemetryProvider.java new file mode 100644 index 000000000..2e9f305ee --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationTelemetryProvider.java @@ -0,0 +1,66 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.time.Instant; + +import software.amazon.awssdk.services.toolkittelemetry.model.MetricDatum; +import software.aws.toolkits.eclipse.amazonq.plugin.Activator; +import software.aws.toolkits.telemetry.TelemetryDefinitions.Component; +import software.aws.toolkits.telemetry.TelemetryDefinitions.Result; +import software.aws.toolkits.telemetry.ToolkitTelemetry; + +/** + * Emits notification telemetry ({@code toolkit_showNotification} / {@code toolkit_invokeAction}). Emission routes + * through {@code DefaultTelemetryService.emitMetric}, which respects the telemetry opt-in independently of the + * notifications feature. The metric {@code id} is the raw notification id (no {@code TARGETED_NOTIFICATION:} prefix). + */ +public final class NotificationTelemetryProvider { + + private NotificationTelemetryProvider() { + // prevent instantiation + } + + /** A notification was shown to the user. */ + public static void emitShowNotification(final String notificationId) { + final MetricDatum datum = ToolkitTelemetry.ShowNotificationEvent() + .id(notificationId) + .component(Component.INFOBAR) + .result(Result.SUCCEEDED) + .passive(true) + .createTime(Instant.now()) + .value(1.0) + .build(); + Activator.getTelemetryService().emitMetric(datum); + } + + /** A poll cycle failed to retrieve notifications. */ + public static void emitPollFailure(final String reason) { + final MetricDatum datum = ToolkitTelemetry.ShowNotificationEvent() + .id("") + .component(Component.FILESYSTEM) + .result(Result.FAILED) + .reason(reason) + .passive(true) + .createTime(Instant.now()) + .value(1.0) + .build(); + Activator.getTelemetryService().emitMetric(datum); + } + + /** The user clicked an action button on a notification. */ + public static void emitInvokeAction(final String notificationId, final String actionType) { + final MetricDatum datum = ToolkitTelemetry.InvokeActionEvent() + .id(notificationId) + .source(notificationId) + .action(actionType) + .component(Component.INFOBAR) + .result(Result.SUCCEEDED) + .passive(false) + .createTime(Instant.now()) + .value(1.0) + .build(); + Activator.getTelemetryService().emitMetric(datum); + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsFetcher.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsFetcher.java new file mode 100644 index 000000000..83d64dc26 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsFetcher.java @@ -0,0 +1,215 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.util.Optional; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import software.aws.toolkits.eclipse.amazonq.plugin.Activator; +import software.aws.toolkits.eclipse.amazonq.util.HttpClientFactory; +import software.aws.toolkits.eclipse.amazonq.util.ObjectMapperFactory; +import software.aws.toolkits.eclipse.amazonq.util.PluginUtils; + +/** + * Fetches the hosted notifications payload with ETag conditional GET + on-disk caching, modeled on + * {@code VersionManifestFetcher}. {@link #fetch()} is a TOTAL function: any failure (absent file, 403/404, empty body, + * malformed JSON, network error) resolves to {@link Optional#empty()} and is only logged — it never throws and never + * surfaces a user-facing popup, so a not-yet-deployed endpoint is a silent no-op. + */ +public final class NotificationsFetcher { + + // A small hosted JSON over CloudFront returns in well under a second; a 10s timeout bounds how long a poll can + // occupy the shared worker thread while still tolerating a slow network. Worst case per poll is bounded to + // roughly MAX_RETRIES * TIMEOUT_SECONDS + total backoff. + private static final int TIMEOUT_SECONDS = 10; + private static final int MAX_RETRIES = 3; + private static final long RETRY_BASE_DELAY_MS = 500L; + /** Upper bound on the notifications payload size (~1MB of JSON). Real payloads are a few KB. */ + private static final int MAX_PAYLOAD_CHARS = 1_000_000; + private static final ObjectMapper OBJECT_MAPPER = ObjectMapperFactory.getInstance(); + + private final String endpointUrl; + private final HttpClient httpClient; + private final Path cachePath; + + public NotificationsFetcher(final String endpointUrl) { + this(endpointUrl, null, null); + } + + public NotificationsFetcher(final String endpointUrl, final HttpClient httpClient, final Path cachePath) { + // Trim stray whitespace/newlines (a common copy-paste artifact when the endpoint is set via env var / preference). + this.endpointUrl = endpointUrl == null ? null : endpointUrl.trim(); + this.httpClient = httpClient != null ? httpClient : HttpClientFactory.getInstance(); + this.cachePath = cachePath != null ? cachePath + : PluginUtils.getPluginDir(NotificationConstants.NOTIFICATIONS_SUBDIRECTORY) + .resolve(NotificationConstants.NOTIFICATIONS_CACHE_FILENAME); + } + + /** Never throws. Returns the parsed notifications, or empty when there is nothing to show. */ + public Optional fetch() { + try { + if (endpointUrl == null || endpointUrl.isBlank()) { + return getResourceFromCache(); + } + if (endpointUrl.regionMatches(true, 0, "file:", 0, 5)) { + return readLocalFile(endpointUrl); + } + return fetchRemoteWithRetries(); + } catch (Exception e) { + Activator.getLogger().warn("Unexpected error fetching notifications", e); + return Optional.empty(); + } + } + + private Optional fetchRemoteWithRetries() { + final Optional cached = getResourceFromCache(); + final String cachedEtag = Activator.getPluginStore().get(endpointUrl); + final String etagToRequest = cached.isPresent() && cachedEtag != null ? cachedEtag : null; + + Exception lastTransient = null; + for (int attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + final HttpResponse response = getResourceFromRemote(etagToRequest); + final int status = response.statusCode(); + + if (status == HttpURLConnection.HTTP_NOT_MODIFIED) { + if (cached.isPresent()) { + return cached; + } + // ETag stored but cache is gone/invalid: clear it so the next poll re-fetches fresh. + Activator.getLogger().warn("Notifications returned 304 but cache is missing; clearing ETag"); + Activator.getPluginStore().remove(endpointUrl); + return Optional.empty(); + } + if (status == HttpURLConnection.HTTP_OK) { + return validateAndCache(response); + } + // 403/404 (file not deployed yet) and any other non-2xx: not an error condition, show nothing. + Activator.getLogger().info("No notifications available (HTTP " + status + ")"); + return Optional.empty(); + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + return cached; + } + lastTransient = e; + sleepBeforeRetry(attempt); + } + } + Activator.getLogger().warn("Failed to fetch notifications after retries; using cache if present", lastTransient); + return cached; + } + + private HttpResponse getResourceFromRemote(final String etag) throws IOException, InterruptedException { + final HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(endpointUrl)) + .timeout(Duration.ofSeconds(TIMEOUT_SECONDS)); + Optional.ofNullable(etag).ifPresent(tag -> requestBuilder.header("If-None-Match", tag)); + return httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private Optional readLocalFile(final String fileUrl) { + try { + // Prefer strict URI parsing; fall back to stripping the scheme for a plain path if the URI is not + // strictly legal (e.g. an un-encoded path pasted as file:///...). + Path path; + try { + path = Path.of(URI.create(fileUrl)); + } catch (IllegalArgumentException e) { + path = Path.of(fileUrl.replaceFirst("(?i)^file://", "")); + } + return validate(Files.readString(path)); + } catch (Exception e) { + Activator.getLogger().warn("Failed to read local notifications file: " + fileUrl, e); + return Optional.empty(); + } + } + + private Optional getResourceFromCache() { + try { + if (Files.exists(cachePath)) { + final Optional parsed = validate(Files.readString(cachePath)); + if (parsed.isEmpty()) { + Files.deleteIfExists(cachePath); + Activator.getLogger().info("Deleted corrupt cached notifications file"); + } + return parsed; + } + } catch (Exception e) { + Activator.getLogger().warn("Error reading cached notifications", e); + } + return Optional.empty(); + } + + private Optional validate(final String content) { + if (content == null || content.isBlank()) { + return Optional.empty(); + } + // Guard against an unexpectedly large payload (a mis-pointed endpoint, or a hijacked/oversized file) so a + // poll cannot buffer an arbitrary amount into memory + attempt to parse it. + if (content.length() > MAX_PAYLOAD_CHARS) { + Activator.getLogger().warn("Notifications payload exceeds " + MAX_PAYLOAD_CHARS + + " chars (" + content.length() + "); ignoring"); + return Optional.empty(); + } + try { + return Optional.ofNullable(OBJECT_MAPPER.readValue(content, NotificationsList.class)); + } catch (Exception e) { + Activator.getLogger().warn("Failed to parse notifications payload", e); + return Optional.empty(); + } + } + + private Optional validateAndCache(final HttpResponse response) { + final String body = response.body(); + final Optional parsed = validate(body); + if (parsed.isEmpty()) { + // Do not cache a bad body; keep any prior valid cache untouched. + return getResourceFromCache(); + } + Path tmp = null; + try { + tmp = cachePath.resolveSibling(cachePath.getFileName() + ".tmp"); + Files.createDirectories(cachePath.getParent()); + Files.writeString(tmp, body); + Files.move(tmp, cachePath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + // Persist the ETag ONLY after the cache write succeeds, so a later If-None-Match 304 can be honored by + // the on-disk cache. Storing the ETag without a matching cache would make 304s unserveable. + response.headers().firstValue("ETag") + .ifPresent(etag -> Activator.getPluginStore().put(endpointUrl, etag)); + } catch (Exception e) { + Activator.getLogger().warn("Failed to cache notifications file", e); + // Clean up a leaked temp file if the atomic move did not consume it. + if (tmp != null) { + try { + Files.deleteIfExists(tmp); + } catch (Exception cleanupError) { + Activator.getLogger().warn("Failed to delete temp notifications file", cleanupError); + } + } + } + return parsed; + } + + private void sleepBeforeRetry(final int attempt) { + if (attempt >= MAX_RETRIES - 1) { + return; + } + try { + Thread.sleep(RETRY_BASE_DELAY_MS * (1L << attempt)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsList.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsList.java new file mode 100644 index 000000000..fc128e8ce --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsList.java @@ -0,0 +1,17 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.util.List; + +/** + * Root of the hosted notifications file: {@code { "schema": { "version": "2.0" }, "notifications": [ ... ] }}. + * The schema version is parsed but not validated (matching the JetBrains client), and {@code notifications} + * may be {@code null} or empty when there is nothing to show. + */ +public record NotificationsList(Schema schema, List notifications) { + + /** Schema descriptor; only the version string is carried. */ + public record Schema(String version) { } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/ProcessNotifications.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/ProcessNotifications.java new file mode 100644 index 000000000..571b3813a --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/ProcessNotifications.java @@ -0,0 +1,150 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.PlatformUI; + +import software.aws.toolkits.eclipse.amazonq.notifications.AmazonQNotificationPopup.NotificationAction; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.LocalizedContent; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationScheduleType; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationSeverity; +import software.aws.toolkits.eclipse.amazonq.plugin.Activator; + +/** + * Filters a fetched notifications payload and shows the survivors. Runs on the poll (worker) thread: it snapshots the + * system/auth state once, applies STARTUP-once + dismissal + rules + in-session dedup, then marshals each surviving + * toast onto the SWT UI thread. STARTUP notifications show only on the first poll of a session; an undismissed EMERGENCY + * shows once per session (guarded by an in-memory set) rather than re-toasting on every poll. + */ +public final class ProcessNotifications { + + /** + * Renders a notification that has passed all filtering. Injectable so tests can observe without SWT. + * The {@code completion} consumer must be invoked with {@code true} once the toast has actually rendered + * (so we only then commit telemetry + consume the startup window) or {@code false} if rendering was skipped + * or failed (so the notification can be retried on a later poll). + */ + public interface NotificationDisplay { + void show(String id, NotificationData notification, LocalizedContent content, + List actions, java.util.function.Consumer completion); + } + + private final AtomicBoolean startupWindowOpen = new AtomicBoolean(true); + private final Set shownThisSession = ConcurrentHashMap.newKeySet(); + private final NotificationDismissalStore dismissalStore; + private final NotificationDisplay display; + + public ProcessNotifications(final NotificationDismissalStore dismissalStore) { + this(dismissalStore, ProcessNotifications::showToast); + } + + public ProcessNotifications(final NotificationDismissalStore dismissalStore, final NotificationDisplay display) { + this.dismissalStore = dismissalStore; + this.display = display; + } + + public void process(final NotificationsList list) { + if (list == null || list.notifications() == null || list.notifications().isEmpty()) { + return; + } + // Whether STARTUP notifications are still eligible this session. Consumed only once a STARTUP notification + // actually survives all filters and is displayed (see processOne) — NOT merely because the first poll ran — + // so a STARTUP item that is dismissed/rule-filtered/blank on the first poll can still show on a later poll + // in the same session once it qualifies. + final boolean startupEligible = startupWindowOpen.get(); + final SystemDetails sys = SystemDetailsCollector.collect(); + + for (final NotificationData notification : list.notifications()) { + if (notification == null) { + continue; + } + try { + processOne(notification, startupEligible, sys); + } catch (Exception e) { + Activator.getLogger().warn("Skipping notification that failed to process: " + notification.id(), e); + } + } + } + + private void processOne(final NotificationData notification, final boolean startupEligible, + final SystemDetails sys) { + final String id = notification.id(); + if (id == null) { + return; + } + final boolean isStartup = notification.schedule() != null + && notification.schedule().type() == NotificationScheduleType.STARTUP; + if (isStartup && !startupEligible) { + return; + } + if (dismissalStore.isDismissed(id)) { + return; + } + if (!RulesEngine.displayNotification(notification, sys)) { + return; + } + if (notification.content() == null || notification.content().enUs() == null) { + Activator.getLogger().info("Skipping notification with no en-US content: " + id); + return; + } + final LocalizedContent content = notification.content().enUs(); + if (isBlank(content.title()) || isBlank(content.description())) { + Activator.getLogger().info("Skipping notification with blank title/description: " + id); + return; + } + if (!shownThisSession.add(id)) { + return; + } + final List actions = new ArrayList<>(NotificationActionFactory.createActions( + id, notification.actions(), content.title(), content.description())); + // The explicit "Dismiss" button persists the dismissal so the notification does not reappear; + // closing/auto-fading or clicking another action does NOT dismiss (an emergency re-shows next session). + actions.add(new NotificationAction("Dismiss", () -> dismissalStore.dismiss(id))); + final boolean isStartupNotification = isStartup; + // Telemetry + startup-window consumption are committed only after the toast actually renders (completion + // == true). If rendering is skipped/failed, un-mark it so a later poll can retry. + display.show(id, notification, content, actions, rendered -> { + if (Boolean.TRUE.equals(rendered)) { + NotificationTelemetryProvider.emitShowNotification(id); + if (isStartupNotification) { + startupWindowOpen.set(false); + } + } else { + shownThisSession.remove(id); + } + }); + } + + private static void showToast(final String id, final NotificationData notification, final LocalizedContent content, + final List actions, final java.util.function.Consumer completion) { + final NotificationSeverity severity = NotificationSeverity.fromString(notification.severity()); + Activator.getLogger().info("Showing notification toast: " + id + " (severity=" + severity + ")"); + Display.getDefault().asyncExec(() -> { + if (!PlatformUI.isWorkbenchRunning()) { + Activator.getLogger().info("Workbench not running; skipping notification toast: " + id); + completion.accept(false); + return; + } + try { + new AmazonQNotificationPopup(Display.getCurrent(), content.title(), content.description(), severity, + actions).open(); + completion.accept(true); + } catch (Exception e) { + Activator.getLogger().error("Failed to render notification toast: " + id, e); + completion.accept(false); + } + }); + } + + private static boolean isBlank(final String s) { + return s == null || s.isBlank(); + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/RulesEngine.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/RulesEngine.java new file mode 100644 index 000000000..779ad25bb --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/RulesEngine.java @@ -0,0 +1,171 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.util.List; +import java.util.Map; + +import org.apache.maven.artifact.versioning.ArtifactVersion; + +import software.aws.toolkits.eclipse.amazonq.lsp.manager.fetcher.ArtifactUtils; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.AuthxType; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.ComputeType; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.ExtensionType; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationDisplayCondition; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.SystemType; + +/** + * Pure evaluator that decides whether a notification's display conditions match the current system/auth state. + * Ported 1:1 from the JetBrains RulesEngine: a null condition matches everyone; a present condition is an AND of its + * five optional blocks; version comparisons use semver only for {@code ide.version} and {@code extension.version}. + */ +public final class RulesEngine { + + private static final String CLEAN_SEMVER = "^\\d+(\\.\\d+)*$"; + + private RulesEngine() { + // prevent instantiation + } + + /** {@code condition == null} shows the notification to everyone. */ + public static boolean displayNotification(final NotificationData notification, final SystemDetails sys) { + final NotificationDisplayCondition condition = notification.condition(); + return condition == null || matchesAllRules(condition, sys); + } + + static boolean matchesAllRules(final NotificationDisplayCondition c, final SystemDetails sys) { + final boolean compute = c.compute() == null + || matchesCompute(c.compute(), sys.computeType(), sys.computeArchitecture()); + final boolean os = c.os() == null || matchesOs(c.os(), sys.osType(), sys.osVersion()); + final boolean ide = c.ide() == null || matchesIde(c.ide(), sys.ideType(), sys.ideVersion()); + final boolean extension = matchesExtension(c.extension(), sys.pluginVersions()); + final boolean authx = matchesAuth(c.authx(), sys); + return compute && os && ide && extension && authx; + } + + private static boolean matchesCompute(final ComputeType nc, final String type, final String arch) { + final boolean typeMatch = nc.type() == null || evaluateNotificationExpression(nc.type(), type); + final boolean archMatch = nc.architecture() == null || evaluateNotificationExpression(nc.architecture(), arch); + return typeMatch && archMatch; + } + + private static boolean matchesOs(final SystemType no, final String os, final String osVersion) { + final boolean typeMatch = no.type() == null || evaluateNotificationExpression(no.type(), os); + final boolean versionMatch = no.version() == null || evaluateNotificationExpression(no.version(), osVersion); + return typeMatch && versionMatch; + } + + private static boolean matchesIde(final SystemType ni, final String ide, final String ideVersion) { + final boolean typeMatch = ni.type() == null || evaluateNotificationExpression(ni.type(), ide); + final boolean versionMatch = ni.version() == null || evaluateNotificationExpression(ni.version(), ideVersion, true); + return typeMatch && versionMatch; + } + + private static boolean matchesExtension(final List ne, final Map installedVersions) { + if (ne == null || ne.isEmpty()) { + return true; + } + boolean anyInstalled = false; + for (final ExtensionType ext : ne) { + final String installed = installedVersions.get(ext.id()); + if (installed == null) { + continue; + } + anyInstalled = true; + // Development builds must never receive notifications. + if (installed.toLowerCase(java.util.Locale.ROOT).contains("snapshot")) { + return false; + } + if (ext.version() != null && !evaluateNotificationExpression(ext.version(), installed, true)) { + return false; + } + } + // Declared but none of the extensions are installed -> do not show. + return anyInstalled; + } + + private static boolean matchesAuth(final List na, final SystemDetails sys) { + if (na == null || na.isEmpty()) { + return true; + } + for (final AuthxType feature : na) { + if (!"q".equals(feature.feature())) { + // Faithful to JetBrains: any non-"q" feature passes. + continue; + } + final FeatureAuthDetails auth = sys.qAuth(); + if (auth == null) { + return false; + } + final boolean typeMatch = feature.type() == null + || evaluateNotificationExpression(feature.type(), auth.connectionType()); + final boolean regionMatch = feature.region() == null + || evaluateNotificationExpression(feature.region(), auth.region()); + final boolean stateMatch = feature.connectionState() == null + || evaluateNotificationExpression(feature.connectionState(), auth.connectionState()); + // NOTE: ssoScopes is intentionally not evaluated here — Eclipse's SystemDetailsCollector does not yet + // collect the connection's SSO scopes, so there is no actual value to compare against. Payloads should + // not rely on ssoScopes for Eclipse targeting until it is collected (tracked as a follow-up); an + // ssoScopes clause is currently a no-op rather than a match/mismatch. + if (!(typeMatch && regionMatch && stateMatch)) { + return false; + } + } + return true; + } + + /** Evaluates an expression against an actual value, using string comparison for ordering operators. */ + public static boolean evaluateNotificationExpression(final NotificationExpression expr, final String value) { + return evaluateNotificationExpression(expr, value, false); + } + + /** Evaluates an expression; when {@code useSemver} is true, ordering operators compare versions semantically. */ + public static boolean evaluateNotificationExpression(final NotificationExpression expr, final String value, + final boolean useSemver) { + if (expr instanceof NotificationExpression.ComparisonCondition c) { + // Anchor on the payload value so a null system value is a non-match, not an NPE. + return c.value() != null && c.value().equals(value); + } else if (expr instanceof NotificationExpression.NotEqualsCondition c) { + return c.value() == null || !c.value().equals(value); + } else if (expr instanceof NotificationExpression.GreaterThanCondition c) { + // A null actual system value cannot satisfy an ordering constraint. + return value != null && compare(value, c.value(), useSemver) > 0; + } else if (expr instanceof NotificationExpression.GreaterThanOrEqualsCondition c) { + return value != null && compare(value, c.value(), useSemver) >= 0; + } else if (expr instanceof NotificationExpression.LessThanCondition c) { + return value != null && compare(value, c.value(), useSemver) < 0; + } else if (expr instanceof NotificationExpression.LessThanOrEqualsCondition c) { + return value != null && compare(value, c.value(), useSemver) <= 0; + } else if (expr instanceof NotificationExpression.AnyOfCondition c) { + return c.value().contains(value); + } else if (expr instanceof NotificationExpression.NoneOfCondition c) { + return !c.value().contains(value); + } else if (expr instanceof NotificationExpression.NotCondition c) { + return !evaluateNotificationExpression(c.expectedValue(), value, useSemver); + } else if (expr instanceof NotificationExpression.OrCondition c) { + return c.expectedValueList().stream().anyMatch(e -> evaluateNotificationExpression(e, value, useSemver)); + } else if (expr instanceof NotificationExpression.AndCondition c) { + return c.expectedValueList().stream().allMatch(e -> evaluateNotificationExpression(e, value, useSemver)); + } + return true; + } + + private static int compare(final String actual, final String expected, final boolean useSemver) { + return useSemver ? compareSemver(actual, expected) : actual.compareTo(expected); + } + + private static int compareSemver(final String actual, final String expected) { + // Match JetBrains: fall back to lexical comparison when either side is not clean numeric semver. + if (!isCleanSemver(actual) || !isCleanSemver(expected)) { + return actual.compareTo(expected); + } + final ArtifactVersion actualVersion = ArtifactUtils.parseVersion(actual); + final ArtifactVersion expectedVersion = ArtifactUtils.parseVersion(expected); + return actualVersion.compareTo(expectedVersion); + } + + private static boolean isCleanSemver(final String v) { + return v != null && v.matches(CLEAN_SEMVER); + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/SystemDetails.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/SystemDetails.java new file mode 100644 index 000000000..c69d022d6 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/SystemDetails.java @@ -0,0 +1,18 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.util.Map; + +/** Immutable snapshot of the current system + auth state that a notification's conditions are evaluated against. */ +public record SystemDetails( + String computeType, + String computeArchitecture, + String osType, + String osVersion, + String ideType, + String ideVersion, + Map pluginVersions, + FeatureAuthDetails qAuth) { +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/SystemDetailsCollector.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/SystemDetailsCollector.java new file mode 100644 index 000000000..e47194b70 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/SystemDetailsCollector.java @@ -0,0 +1,136 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.util.Map; + +import org.eclipse.core.runtime.Platform; +import org.osgi.framework.Bundle; +import org.osgi.framework.FrameworkUtil; +import org.osgi.framework.Version; + +import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.AuthState; +import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.AuthStateType; +import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.LoginType; +import software.aws.toolkits.eclipse.amazonq.plugin.Activator; + +/** Resolves the current system + auth state into an immutable {@link SystemDetails} snapshot for the rules engine. */ +public final class SystemDetailsCollector { + + private static final String PLATFORM_BUNDLE_ID = "org.eclipse.platform"; + private static final String UNKNOWN = "Unknown"; + + private SystemDetailsCollector() { + // prevent instantiation + } + + /** Snapshots {@code getAuthState()} once and resolves everything into an immutable {@link SystemDetails}. */ + public static SystemDetails collect() { + final Bundle pluginBundle = FrameworkUtil.getBundle(SystemDetailsCollector.class); + final String pluginId = pluginBundle != null ? pluginBundle.getSymbolicName() : UNKNOWN; + // Use a clean major.minor.micro string (drop the OSGi qualifier, e.g. "2.7.4.202607161757") so the rules + // engine compares extension.version with semver, not lexical ordering. See resolveIdeVersion for the same. + final String pluginVersion = pluginBundle != null ? cleanVersion(pluginBundle.getVersion()) : UNKNOWN; + + return new SystemDetails( + "Local", + Platform.getOSArch(), + System.getProperty("os.name"), + System.getProperty("os.version"), + "Eclipse", + resolveIdeVersion(), + Map.of(pluginId, pluginVersion), + resolveQAuth()); + } + + /** Amazon Q Eclipse bundle symbolic name — the key notification payloads use for {@code extension.id}. */ + public static String pluginId() { + final Bundle pluginBundle = FrameworkUtil.getBundle(SystemDetailsCollector.class); + return pluginBundle != null ? pluginBundle.getSymbolicName() : UNKNOWN; + } + + /** + * Whether this is an unreleased/development build. Tycho replaces the {@code .qualifier} segment with a numeric + * build timestamp at release time, so a bundle whose qualifier is still the literal {@code "qualifier"} (PDE/dev + * launch) or contains {@code "snapshot"} is a development build that should not receive production notifications. + */ + public static boolean isDevBuild() { + final Bundle pluginBundle = FrameworkUtil.getBundle(SystemDetailsCollector.class); + if (pluginBundle == null) { + return true; + } + final String qualifier = pluginBundle.getVersion().getQualifier(); + if (qualifier == null || qualifier.isBlank()) { + return false; + } + final String q = qualifier.toLowerCase(java.util.Locale.ROOT); + return q.equals("qualifier") || q.contains("snapshot"); + } + + private static String resolveIdeVersion() { + final Bundle platform = Platform.getBundle(PLATFORM_BUNDLE_ID); + if (platform == null) { + return UNKNOWN; + } + return cleanVersion(platform.getVersion()); + } + + /** Renders an OSGi {@link Version} as clean {@code major.minor.micro}, dropping the qualifier segment. */ + private static String cleanVersion(final Version v) { + if (v == null) { + return UNKNOWN; + } + return v.getMajor() + "." + v.getMinor() + "." + v.getMicro(); + } + + private static FeatureAuthDetails resolveQAuth() { + final AuthState authState = Activator.getLoginService().getAuthState(); + if (authState == null) { + return new FeatureAuthDetails(UNKNOWN, UNKNOWN, "NotConnected"); + } + return new FeatureAuthDetails( + mapConnectionType(authState.loginType()), + mapRegion(authState), + mapConnectionState(authState.authStateType())); + } + + private static String mapConnectionType(final LoginType loginType) { + if (loginType == null) { + return UNKNOWN; + } + switch (loginType) { + case BUILDER_ID: + return "BuilderId"; + case IAM_IDENTITY_CENTER: + return "Idc"; + default: + return UNKNOWN; + } + } + + private static String mapConnectionState(final AuthStateType authStateType) { + if (authStateType == null) { + return "NotConnected"; + } + switch (authStateType) { + case LOGGED_IN: + return "Connected"; + case EXPIRED: + return "Expired"; + case LOGGED_OUT: + default: + return "NotConnected"; + } + } + + private static String mapRegion(final AuthState authState) { + if (authState.loginParams() != null && authState.loginParams().getLoginIdcParams() != null) { + final String region = authState.loginParams().getLoginIdcParams().getRegion(); + if (region != null && !region.isBlank()) { + return region; + } + } + return UNKNOWN; + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/plugin/Activator.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/plugin/Activator.java index 9fc1dbb60..fcd3221a6 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/plugin/Activator.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/plugin/Activator.java @@ -12,6 +12,7 @@ import software.aws.toolkits.eclipse.amazonq.inlineChat.InlineChatEditorListener; import software.aws.toolkits.eclipse.amazonq.lsp.auth.DefaultLoginService; import software.aws.toolkits.eclipse.amazonq.lsp.auth.LoginService; +import software.aws.toolkits.eclipse.amazonq.lsp.editor.ActiveEditorChangeListener; import software.aws.toolkits.eclipse.amazonq.providers.browser.AmazonQBrowserProvider; import software.aws.toolkits.eclipse.amazonq.providers.lsp.LspProvider; import software.aws.toolkits.eclipse.amazonq.providers.lsp.LspProviderImpl; @@ -21,6 +22,7 @@ import software.aws.toolkits.eclipse.amazonq.util.DefaultCodeReferenceLoggingService; import software.aws.toolkits.eclipse.amazonq.util.LoggingService; import software.aws.toolkits.eclipse.amazonq.util.PluginLogger; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationPollingService; import software.aws.toolkits.eclipse.amazonq.util.ThreadingUtils; import software.aws.toolkits.eclipse.amazonq.views.router.ViewRouter; import software.aws.toolkits.eclipse.workspace.WorkspaceChangeListener; @@ -39,6 +41,7 @@ public class Activator extends AbstractUIPlugin { private static ViewRouter viewRouter = ViewRouter.builder().build(); private final InlineChatEditorListener editorListener; private static WorkspaceChangeListener workspaceListener = WorkspaceChangeListener.getInstance(); + private static ActiveEditorChangeListener activeEditorListener = ActiveEditorChangeListener.getInstance(); public Activator() { super(); @@ -56,14 +59,17 @@ public Activator() { editorListener = InlineChatEditorListener.getInstance(); editorListener.initialize(); workspaceListener.start(); + activeEditorListener.initialize(); } @Override public final void stop(final BundleContext context) throws Exception { + NotificationPollingService.getInstance().shutdown(); AmazonQBrowserProvider.getInstance().dispose(); super.stop(context); plugin = null; workspaceListener.stop(); + activeEditorListener.stop(); ThreadingUtils.shutdown(); } diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferenceInitializer.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferenceInitializer.java index a1cb028b0..8f5c2fe8f 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferenceInitializer.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferenceInitializer.java @@ -9,6 +9,7 @@ import org.eclipse.jface.preference.IPreferenceStore; import org.eclipse.lsp4j.DidChangeConfigurationParams; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationPollingService; import software.aws.toolkits.eclipse.amazonq.plugin.Activator; import software.aws.toolkits.eclipse.amazonq.util.ThreadingUtils; @@ -18,14 +19,17 @@ public class AmazonQPreferenceInitializer extends AbstractPreferenceInitializer public final void initializeDefaultPreferences() { IPreferenceStore store = Activator.getDefault().getPreferenceStore(); store.setDefault(AmazonQPreferencePage.CODE_REFERENCE_OPT_IN, true); - store.setDefault(AmazonQPreferencePage.WORKSPACE_INDEX, false); - store.setDefault(AmazonQPreferencePage.USE_GPU_FOR_INDEXING, false); - store.setDefault(AmazonQPreferencePage.INDEX_WORKER_THREADS, 0); store.setDefault(AmazonQPreferencePage.TELEMETRY_OPT_IN, true); store.setDefault(AmazonQPreferencePage.Q_DATA_SHARING, true); store.setDefault(AmazonQPreferencePage.HTTPS_PROXY, ""); store.setDefault(AmazonQPreferencePage.CA_CERT, ""); + store.setDefault(AmazonQPreferencePage.NOTIFICATIONS_OPT_IN, true); + store.setDefault(AmazonQPreferencePage.NOTIFICATIONS_ENDPOINT_OVERRIDE, ""); store.addPropertyChangeListener(event -> { + // React to the notifications kill-switch so it can pause/resume polling within a session (no restart). + if (AmazonQPreferencePage.NOTIFICATIONS_OPT_IN.equals(event.getProperty())) { + NotificationPollingService.getInstance().onEnabledPreferenceChanged(); + } ThreadingUtils.executeAsyncTask(() -> { Activator.getLspProvider().getAmazonQServer() .thenAccept(server -> server.getWorkspaceService().didChangeConfiguration( diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferencePage.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferencePage.java index 9d4c83d1c..7dee941f9 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferencePage.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferencePage.java @@ -36,21 +36,12 @@ public class AmazonQPreferencePage extends FieldEditorPreferencePage implements IWorkbenchPreferencePage { public static final String PREFERENCE_STORE_ID = "software.aws.toolkits.eclipse.preferences"; public static final String CODE_REFERENCE_OPT_IN = "codeReferenceOptIn"; - public static final String WORKSPACE_INDEX = "workspaceIndex"; - public static final String USE_GPU_FOR_INDEXING = "useGpuForIndexing"; - public static final String INDEX_WORKER_THREADS = "indexWorkerThreads"; public static final String TELEMETRY_OPT_IN = "telemetryOptIn"; public static final String Q_DATA_SHARING = "qDataSharing"; public static final String HTTPS_PROXY = "httpsProxy"; public static final String CA_CERT = "customCaCert"; - - private Boolean isWorkspaceIndexChecked; - private Boolean isGpuIndexingChecked; - private int indexWorkerThreads; - - private Boolean changedWorkspaceIndexChecked; - private Boolean changedGpuIndexingChecked; - private int changedIndexWorkerThreads; + public static final String NOTIFICATIONS_OPT_IN = "notificationsOptIn"; + public static final String NOTIFICATIONS_ENDPOINT_OVERRIDE = "notificationsEndpointOverride"; private Boolean isTelemetryOptInChecked; private Boolean isQDataSharingOptInChecked; @@ -67,12 +58,6 @@ public AmazonQPreferencePage() { @Override public final void init(final IWorkbench workbench) { - isWorkspaceIndexChecked = preferenceStore.getBoolean(WORKSPACE_INDEX); - changedWorkspaceIndexChecked = preferenceStore.getBoolean(WORKSPACE_INDEX); - isGpuIndexingChecked = preferenceStore.getBoolean(USE_GPU_FOR_INDEXING); - changedGpuIndexingChecked = preferenceStore.getBoolean(USE_GPU_FOR_INDEXING); - indexWorkerThreads = preferenceStore.getInt(INDEX_WORKER_THREADS); - changedIndexWorkerThreads = preferenceStore.getInt(INDEX_WORKER_THREADS); isTelemetryOptInChecked = preferenceStore.getBoolean(TELEMETRY_OPT_IN); changedTelemetryOptInChecked = preferenceStore.getBoolean(TELEMETRY_OPT_IN); isQDataSharingOptInChecked = preferenceStore.getBoolean(Q_DATA_SHARING); @@ -87,14 +72,12 @@ protected final void createFieldEditors() { createHorizontalSeparator(); createHeading("Code Suggestions"); createCodeReferenceOptInField(); - createHeading("Workspace Indexing"); - createWorkspaceIndexField(); - createUseGpuForIndexingField(); - createIndexWorkerThreadsField(); createHeading("Data Sharing"); createTelemetryOptInField(); createHorizontalSeparator(); createQDataSharingField(); + createHeading("Notifications"); + createNotificationsOptInField(); createHeading("Proxy Settings"); createHttpsProxyField(); createCaCertField(); @@ -148,65 +131,6 @@ public void widgetSelected(final SelectionEvent event) { }); } - private void createWorkspaceIndexField() { - Composite workspaceIndexComposite = new Composite(getFieldEditorParent(), SWT.NONE); - workspaceIndexComposite.setLayout(new GridLayout(2, false)); - GridData workspaceIndexCompositeData = new GridData(SWT.FILL, SWT.CENTER, true, false); - workspaceIndexCompositeData.horizontalIndent = 20; - workspaceIndexComposite.setLayoutData(workspaceIndexCompositeData); - - BooleanFieldEditor workspaceIndex = new BooleanFieldEditor(WORKSPACE_INDEX, "Workspace Index", workspaceIndexComposite) { - @Override - protected void valueChanged(final boolean oldValue, final boolean newValue) { - isWorkspaceIndexChecked = newValue; - } - }; - addField(workspaceIndex); - - createLabel(""" - When you add @workspace to your question in Amazon Q chat, Amazon Q will index your workspace files locally\ - \nto use as context for its response. Extra CPU usage is expected while indexing a workspace. This will not\ - \nimpact Amazon Q features or your IDE, but you may manage CPU usage by setting the number of index threads. - """, 20, workspaceIndexComposite); - } - - private void createUseGpuForIndexingField() { - Composite useGpuComposite = new Composite(getFieldEditorParent(), SWT.NONE); - useGpuComposite.setLayout(new GridLayout(2, false)); - GridData useGpuCompositeData = new GridData(SWT.FILL, SWT.CENTER, true, false); - useGpuCompositeData.horizontalIndent = 20; - useGpuComposite.setLayoutData(useGpuCompositeData); - - BooleanFieldEditor useGpuForIndexing = new BooleanFieldEditor(USE_GPU_FOR_INDEXING, "Use GPU for Indexing", useGpuComposite) { - @Override - protected void valueChanged(final boolean oldValue, final boolean newValue) { - isGpuIndexingChecked = newValue; - } - }; - addField(useGpuForIndexing); - - createLabel(""" - Enable GPU to help index your local workspace files. Only applies to Linux and Windows. - """, 20, useGpuComposite); - } - - private void createIndexWorkerThreadsField() { - Composite indexWorkerThreadsComposite = new Composite(getFieldEditorParent(), SWT.NONE); - indexWorkerThreadsComposite.setLayout(new GridLayout(2, false)); - GridData indexWorkerThreadsCompositeData = new GridData(SWT.LEFT, SWT.CENTER, true, false); - indexWorkerThreadsCompositeData.horizontalIndent = 20; - indexWorkerThreadsComposite.setLayoutData(indexWorkerThreadsCompositeData); - - StringFieldEditor indexWorkerThreads = new StringFieldEditor(INDEX_WORKER_THREADS, "Index Worker Threads", 10, indexWorkerThreadsComposite); - addField(indexWorkerThreads); - - createLabel(""" - Number of worker threads of Amazon Q local index process. '0' will use the system default worker threads for balance\ - \nperformance. You may increase this number to more quickly index your workspace, but only up to your hardware's number\ - \nof CPU cores. Please restart Eclipse after changing worker threads. - """, 20, getFieldEditorParent()); - } - private void createTelemetryOptInField() { Composite telemetryOptInComposite = new Composite(getFieldEditorParent(), SWT.NONE); telemetryOptInComposite.setLayout(new GridLayout(2, false)); @@ -234,6 +158,18 @@ public void widgetSelected(final SelectionEvent event) { }); } + private void createNotificationsOptInField() { + Composite notificationsOptInComposite = new Composite(getFieldEditorParent(), SWT.NONE); + notificationsOptInComposite.setLayout(new GridLayout(2, false)); + GridData notificationsOptInCompositeData = new GridData(SWT.FILL, SWT.CENTER, true, false); + notificationsOptInCompositeData.horizontalIndent = 20; + notificationsOptInComposite.setLayoutData(notificationsOptInCompositeData); + + BooleanFieldEditor notificationsOptIn = new BooleanFieldEditor(NOTIFICATIONS_OPT_IN, + "Show Amazon Q notifications about known issues and available fixes", notificationsOptInComposite); + addField(notificationsOptIn); + } + private void createQDataSharingField() { Composite qDataSharingComposite = new Composite(getFieldEditorParent(), SWT.NONE); qDataSharingComposite.setLayout(new GridLayout(2, false)); @@ -351,23 +287,6 @@ private void sendUpdatedPreferences() { isQDataSharingOptInChecked = changedDataSharingOptInChecked; } - if (changedWorkspaceIndexChecked != isWorkspaceIndexChecked) { - AwsTelemetryProvider.emitModifySettingEvent("amazonQ.workspaceIndexing", - changedWorkspaceIndexChecked.toString()); - isWorkspaceIndexChecked = changedWorkspaceIndexChecked; - } - - if (changedGpuIndexingChecked != isGpuIndexingChecked) { - AwsTelemetryProvider.emitModifySettingEvent("amazonQ.gpuIndexing", - changedGpuIndexingChecked.toString()); - isGpuIndexingChecked = changedGpuIndexingChecked; - } - - if (changedIndexWorkerThreads != indexWorkerThreads) { - AwsTelemetryProvider.emitModifySettingEvent("amazonQ.indexThreads", - String.valueOf(changedIndexWorkerThreads)); - indexWorkerThreads = changedIndexWorkerThreads; - } ThreadingUtils.executeAsyncTask(() -> CustomizationUtil.triggerChangeConfigurationNotification()); } diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/providers/assets/ChatWebViewAssetProvider.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/providers/assets/ChatWebViewAssetProvider.java index 39fce82f2..3b7f2905e 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/providers/assets/ChatWebViewAssetProvider.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/providers/assets/ChatWebViewAssetProvider.java @@ -100,6 +100,8 @@ private Optional resolveContent() { String chatJsPath = chatAsset.get(); String themeVariables = chatTheme.getThemeVariables(); + String webkitWorkarounds = getWebkitProgressWorkaround(); + String eclipseWebkitScript = getEclipseWebkitScript(); return Optional.of(String.format(""" @@ -131,42 +133,75 @@ private Optional resolveContent() { [class*="mynah-ui-icon-"] { transform: translateZ(0); } + %s %s + %s - """, chatJsPath, chatJsPath, themeVariables, generateJS(chatJsPath))); + """, chatJsPath, chatJsPath, themeVariables, webkitWorkarounds, eclipseWebkitScript, generateJS(chatJsPath))); + } + + /** + * Replaces the progress icon with a simplified static spinner on Mac to reduce Webkit rendering issues. + */ + private String getWebkitProgressWorkaround() { + PluginPlatform platform = PluginUtils.getPlatform(); + if (platform != PluginPlatform.WINDOWS) { + return """ + .mynah-ui-icon-progress { + -webkit-mask: none !important; + mask: none !important; + background: none !important; + border: 2px solid currentColor !important; + border-top-color: transparent !important; + border-radius: 50% !important; + } + + .mynah-ui-icon-progress-subtract { + mask: none !important; + background: none !important; + border: 2px solid currentColor !important; + border-top-color: transparent !important; + border-radius: 50% !important; + } + """; + } + return ""; + } + + /** + * Injects a script to the UI JS to pass in a flag when SWT Webkit is used. + * The backing code has logic to skip styling that adds transparency layers + * that can potentially cause rendering issues with Webkit on Mac + * @return + */ + private String getEclipseWebkitScript() { + PluginPlatform platform = PluginUtils.getPlatform(); + if (platform != PluginPlatform.WINDOWS) { + return ""; + } + return ""; } private String generateJS(final String jsEntrypoint) { var disclaimerAcknowledged = Activator.getPluginStore().get(PluginStoreKeys.CHAT_DISCLAIMER_ACKNOWLEDGED); var pairProgrammingAcknowledged = Activator.getPluginStore().get(PluginStoreKeys.PAIR_PROGRAMMING_ACKNOWLEDGED); return String.format(""" - + diff --git a/plugin/webview/src/q-ui/components/selectableItem.vue b/plugin/webview/src/q-ui/components/selectableItem.vue index 22a2909db..358ce698b 100644 --- a/plugin/webview/src/q-ui/components/selectableItem.vue +++ b/plugin/webview/src/q-ui/components/selectableItem.vue @@ -6,8 +6,8 @@

.item-container { - padding: 15px; + padding: 10px 12px; display: flex; align-items: center; - height: 38px; + min-height: 50px; + box-sizing: border-box; } .selected { @@ -105,6 +106,7 @@ export default defineComponent({ } .item-title { + font-size: 13px; font-weight: bold; margin-bottom: 2px; } @@ -112,11 +114,20 @@ export default defineComponent({ .text { display: flex; flex-direction: column; - font-size: 15px; + font-size: 12px; + min-width: 0; +} + +.p { + line-height: 1.4; + white-space: normal; } .icon { - margin-right: 15px; + margin-right: 11px; + display: flex; + align-items: center; + flex-shrink: 0; } /* Theme specific styles */ diff --git a/plugin/webview/src/q-ui/components/ssoLoginForm.vue b/plugin/webview/src/q-ui/components/ssoLoginForm.vue index 1dedaf8ba..9a905b3ad 100644 --- a/plugin/webview/src/q-ui/components/ssoLoginForm.vue +++ b/plugin/webview/src/q-ui/components/ssoLoginForm.vue @@ -68,9 +68,41 @@ export default defineComponent({ app: String }, data() { + const info = this.$store.state.lastLoginIdcInfo return { startUrlRegex: /^https:\/\/(([\w-]+(?:\.gamma)?\.awsapps\.com\/start(?:-beta|-alpha)?[\/#]?)|(start\.(?:us-gov-home|us-gov-east-1\.us-gov-home|us-gov-west-1\.us-gov-home)\.awsapps\.com|start\.(?:home|cn-north-1\.home|cn-northwest-1\.home)\.awsapps\.cn)\/directory\/[\w-]+[\/#]?)$/, - issueUrlRegex: /^https:\/\/([\w-]+\.)?identitycenter\.(amazonaws\.com|amazonaws\.com\.cn|us-gov\.amazonaws\.com)\/[\w\/-]+[\/#]?$/ + issueUrlRegex: /^https:\/\/([\w-]+\.)?identitycenter\.(amazonaws\.com|amazonaws\.com\.cn|us-gov\.amazonaws\.com)\/[\w\/-]+[\/#]?$/, + startUrl: info.startUrl || '', + selectedRegion: info.region || 'us-east-1' + } + }, + // SWT Browser + Vue 3 quirk: the mounted() hook on this component does not fire reliably, + // so v-model's initial DOM sync is skipped. Push the persisted Start URL into the input + // from created() via $nextTick — that callback runs after Vue's next render flush and + // does not depend on the mounted lifecycle hook firing. + created() { + const expected = this.startUrl + if (!expected) return + this.$nextTick(() => { + const el = document.getElementById("startUrl") as HTMLInputElement | null + if (!el || el.value) return // don't overwrite if user already typed + el.value = expected + el.dispatchEvent(new Event('input', { bubbles: true })) + el.focus() + }) + }, + watch: { + startUrl(value: string) { + window.ideClient.updateLastLoginIdcInfo({ + ...this.$store.state.lastLoginIdcInfo, + startUrl: value + }) + }, + selectedRegion(value: string) { + window.ideClient.updateLastLoginIdcInfo({ + ...this.$store.state.lastLoginIdcInfo, + region: value + }) } }, computed: { @@ -79,7 +111,7 @@ export default defineComponent({ const otherRegions = this.regions .filter(r => r.id !== 'us-east-1') .sort((a, b) => a.name.localeCompare(b.name)); - + return usEast1 ? [usEast1, ...otherRegions] : otherRegions; }, regions(): Region[] { @@ -88,28 +120,6 @@ export default defineComponent({ feature(): Feature { return this.$store.state.feature }, - startUrl: { - get() { - return this.$store.state.lastLoginIdcInfo.startUrl; - }, - set(value: string) { - window.ideClient.updateLastLoginIdcInfo({ - ...this.$store.state.lastLoginIdcInfo, - startUrl: value - }) - } - }, - selectedRegion: { - get() { - return this.$store.state.lastLoginIdcInfo.region; - }, - set(value: string) { - window.ideClient.updateLastLoginIdcInfo({ - ...this.$store.state.lastLoginIdcInfo, - region: value - }) - } - }, isStartUrlValid: { get() { return this.startUrlRegex.test(this.startUrl) || this.issueUrlRegex.test(this.startUrl) @@ -152,9 +162,6 @@ export default defineComponent({ handleCodeCatalystSignin() { this.$emit('login', new BuilderId()) } - }, - mounted() { - document.getElementById("startUrl")?.focus() } }) diff --git a/pom.xml b/pom.xml index 4e9470f42..88eec8919 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ software.aws.toolkits.eclipse amazon-q-eclipse-group - 2.2.1-SNAPSHOT + 2.7.5-SNAPSHOT pom diff --git a/telemetry/pom.xml b/telemetry/pom.xml index 3369c2dea..661ec38f3 100644 --- a/telemetry/pom.xml +++ b/telemetry/pom.xml @@ -6,7 +6,7 @@ software.aws.toolkits.eclipse amazon-q-eclipse-group - 2.2.1-SNAPSHOT + 2.7.5-SNAPSHOT ../ diff --git a/update-version.sh b/update-version.sh new file mode 100644 index 000000000..047ec09f0 --- /dev/null +++ b/update-version.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +if [ -z "$1" ]; then + echo "Usage: ./update-version.sh [new-version]" + echo "Example: ./update-version.sh 2.4.0" + exit 1 +fi + +NEW_VERSION=$1 + +# Validate version format (x.x.x) +if ! [[ $NEW_VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Version must be in format x.x.x (e.g., 2.4.0)" + echo "Provided: $NEW_VERSION" + exit 1 +fi + +echo "Updating to version $NEW_VERSION" + +# Update root pom.xml version +sed -i "s/.*-SNAPSHOT<\/version>/$NEW_VERSION-SNAPSHOT<\/version>/" pom.xml + +# Update parent version in all child pom.xml files +find . -name "pom.xml" -not -path "./pom.xml" -exec sed -i "s/.*-SNAPSHOT<\/version>/$NEW_VERSION-SNAPSHOT<\/version>/" {} \; + +# Update MANIFEST.MF +sed -i "s/Bundle-Version: .*/Bundle-Version: $NEW_VERSION.qualifier/" plugin/META-INF/MANIFEST.MF + +# Update feature.xml +sed -i "s/version=\".*\.qualifier\"/version=\"$NEW_VERSION.qualifier\"/g" feature/feature.xml + +# Update category.xml +sed -i "s/version=\".*\.qualifier\"/version=\"$NEW_VERSION.qualifier\"/g" updatesite/category.xml +sed -i "s/_.*\.qualifier\.jar/_$NEW_VERSION.qualifier.jar/g" updatesite/category.xml + +echo "Version updated to $NEW_VERSION" +echo "Run 'mvn clean install' and 'mvn clean package' to build with new version" \ No newline at end of file diff --git a/updatesite/category.xml b/updatesite/category.xml index ff0bd177b..3ac09a107 100644 --- a/updatesite/category.xml +++ b/updatesite/category.xml @@ -1,6 +1,6 @@ - + diff --git a/updatesite/pom.xml b/updatesite/pom.xml index 7b447976d..a35df53ac 100644 --- a/updatesite/pom.xml +++ b/updatesite/pom.xml @@ -6,7 +6,7 @@ software.aws.toolkits.eclipse amazon-q-eclipse-group - 2.2.1-SNAPSHOT + 2.7.5-SNAPSHOT ../