diff --git a/plugin-core/src/main/java/appland/index/AppMapFindingsUtil.java b/plugin-core/src/main/java/appland/index/AppMapFindingsUtil.java index 58715071..9ea4338d 100644 --- a/plugin-core/src/main/java/appland/index/AppMapFindingsUtil.java +++ b/plugin-core/src/main/java/appland/index/AppMapFindingsUtil.java @@ -2,7 +2,10 @@ import com.intellij.openapi.util.io.FileUtil; import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.search.FilenameIndex; +import com.intellij.psi.search.GlobalSearchScope; import com.intellij.util.PathUtil; +import com.intellij.util.concurrency.annotations.RequiresReadLock; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -19,4 +22,9 @@ public static boolean isFindingFile(@NotNull String path) { public static boolean isFindingFile(@Nullable VirtualFile file) { return file != null && FileUtil.fileNameEquals(file.getName(), FINDINGS_FILE_NAME); } + + @RequiresReadLock + public static boolean isAnalysisPerformed(GlobalSearchScope searchScope) { + return !FilenameIndex.getVirtualFilesByName(FINDINGS_FILE_NAME, searchScope).isEmpty(); + } } diff --git a/plugin-core/src/main/java/appland/installGuide/InstallGuideEditor.java b/plugin-core/src/main/java/appland/installGuide/InstallGuideEditor.java index 6276a40d..498f6890 100644 --- a/plugin-core/src/main/java/appland/installGuide/InstallGuideEditor.java +++ b/plugin-core/src/main/java/appland/installGuide/InstallGuideEditor.java @@ -8,11 +8,9 @@ import appland.installGuide.projectData.ProjectDataService; import appland.installGuide.projectData.ProjectMetadata; import appland.oauth.AppMapLoginAction; -import appland.problemsView.FindingsViewTab; import appland.settings.AppMapApplicationSettingsService; import appland.settings.AppMapProjectSettingsService; import appland.settings.AppMapSettingsListener; -import appland.telemetry.TelemetryService; import appland.webviews.WebviewEditor; import appland.webviews.findings.FindingsOverviewEditorProvider; import com.google.gson.Gson; @@ -68,14 +66,23 @@ public InstallGuideEditor(@NotNull Project project, this.currentPage = page; } - public void navigateTo(@NotNull InstallGuideViewPage page, boolean postWebviewMessage) { + /** + * Navigate to the given page. + * + * @param page Target page + * @param postWebviewMessage If the webview should be instructed to navigate to the given page. + * @param fromWebview If the navigation originated from the webview + */ + public void navigateTo(@NotNull InstallGuideViewPage page, boolean postWebviewMessage, boolean fromWebview) { this.currentPage = page; - if (page == InstallGuideViewPage.RuntimeAnalysis) { - AppMapProjectSettingsService.getState(project).setInvestigatedFindings(true); + var projectSettings = AppMapProjectSettingsService.getState(project); + if (fromWebview && page == InstallGuideViewPage.RuntimeAnalysis && projectSettings.isOpenedAppMapEditor()) { + projectSettings.setInvestigatedFindings(true); } if (postWebviewMessage) { + assert !fromWebview; postMessage(createPageNavigationJSON(page)); } } @@ -217,7 +224,7 @@ protected void handleMessage(@NotNull String messageId, @Nullable JsonObject mes @NotNull private List<ProjectMetadata> findProjects() { - return ProjectDataService.getInstance(project).getAppMapProjects(); + return ProjectDataService.getInstance(project).getAppMapProjects(true); } /** @@ -275,7 +282,7 @@ private void handleMessageOpenFile(@NotNull JsonObject message) { private void handleMessageOpenPage(@NotNull JsonObject message) { // update state, which is based on the new page var viewId = message.getAsJsonPrimitive("page").getAsString(); - navigateTo(InstallGuideViewPage.findByPageId(viewId), false); + navigateTo(InstallGuideViewPage.findByPageId(viewId), false, true); } private void executeInstallCommand(String path, String language) { diff --git a/plugin-core/src/main/java/appland/installGuide/InstallGuideEditorProvider.java b/plugin-core/src/main/java/appland/installGuide/InstallGuideEditorProvider.java index 8bbf64b5..acc51c10 100644 --- a/plugin-core/src/main/java/appland/installGuide/InstallGuideEditorProvider.java +++ b/plugin-core/src/main/java/appland/installGuide/InstallGuideEditorProvider.java @@ -35,7 +35,7 @@ public static void open(@NotNull Project project, @NotNull InstallGuideViewPage assert editor instanceof InstallGuideEditor; FileEditorManagerEx.getInstanceEx(project).openFile(file, true, true); - ((InstallGuideEditor) editor).navigateTo(page, true); + ((InstallGuideEditor) editor).navigateTo(page, true, false); return; } } @@ -45,7 +45,7 @@ public static void open(@NotNull Project project, @NotNull InstallGuideViewPage var newEditors = editorManager.openFile(file, true); if (newEditors.length == 1) { // don't post webview message because the init message of the new editor was just sent - ((InstallGuideEditor) newEditors[0]).navigateTo(page, false); + ((InstallGuideEditor) newEditors[0]).navigateTo(page, false, false); } } finally { // notify in a background thread because we don't want to delay opening the editor diff --git a/plugin-core/src/main/java/appland/installGuide/projectData/DefaultProjectDataService.java b/plugin-core/src/main/java/appland/installGuide/projectData/DefaultProjectDataService.java index 6a1a1528..9f6aa948 100644 --- a/plugin-core/src/main/java/appland/installGuide/projectData/DefaultProjectDataService.java +++ b/plugin-core/src/main/java/appland/installGuide/projectData/DefaultProjectDataService.java @@ -19,7 +19,6 @@ import com.intellij.openapi.project.Project; import com.intellij.openapi.vfs.VfsUtilCore; import com.intellij.openapi.vfs.VirtualFile; -import com.intellij.psi.search.FilenameIndex; import com.intellij.psi.search.GlobalSearchScopes; import com.intellij.util.concurrency.annotations.RequiresReadLock; import it.unimi.dsi.fastutil.ints.IntSet; @@ -47,10 +46,11 @@ public DefaultProjectDataService(@NotNull Project project) { @SuppressWarnings("UnstableApiUsage") @Override - public @NotNull List<ProjectMetadata> getAppMapProjects() { - ApplicationManager.getApplication().assertReadAccessNotAllowed(); - - updateMetadata(); + public @NotNull List<ProjectMetadata> getAppMapProjects(boolean updateMetadata) { + if (updateMetadata) { + ApplicationManager.getApplication().assertReadAccessNotAllowed(); + updateMetadata(); + } return cachedProjects.get(); } @@ -203,7 +203,7 @@ private List<SimpleCodeObject> truncateSampleCodeObjects(List<SimpleCodeObject> @RequiresReadLock private boolean isAnalysisPerformed(@NotNull VirtualFile root) { var searchScope = GlobalSearchScopes.directoryScope(project, root, true); - return !FilenameIndex.getVirtualFilesByName(AppMapFindingsUtil.FINDINGS_FILE_NAME, searchScope).isEmpty(); + return AppMapFindingsUtil.isAnalysisPerformed(searchScope); } private boolean isNodeSupported(@Nullable NodeVersion nodeVersion) { diff --git a/plugin-core/src/main/java/appland/installGuide/projectData/ProjectDataService.java b/plugin-core/src/main/java/appland/installGuide/projectData/ProjectDataService.java index 6c087476..81f03c19 100644 --- a/plugin-core/src/main/java/appland/installGuide/projectData/ProjectDataService.java +++ b/plugin-core/src/main/java/appland/installGuide/projectData/ProjectDataService.java @@ -10,5 +10,10 @@ public interface ProjectDataService { return project.getService(ProjectDataService.class); } - @NotNull List<ProjectMetadata> getAppMapProjects(); + /** + * @param updateMetadata If the metadata should be updated before retuning the cached projects. + * If {@code true}, then this method MUST NOT be invoked in a ReadAction. + * @return The available AppMap projects + */ + @NotNull List<ProjectMetadata> getAppMapProjects(boolean updateMetadata); } diff --git a/plugin-core/src/main/java/appland/toolwindow/installGuide/InstallGuidePanel.java b/plugin-core/src/main/java/appland/toolwindow/installGuide/InstallGuidePanel.java index 33536f96..b33e7741 100644 --- a/plugin-core/src/main/java/appland/toolwindow/installGuide/InstallGuidePanel.java +++ b/plugin-core/src/main/java/appland/toolwindow/installGuide/InstallGuidePanel.java @@ -1,6 +1,7 @@ package appland.toolwindow.installGuide; import appland.config.AppMapConfigFileListener; +import appland.index.AppMapFindingsUtil; import appland.index.AppMapNameIndex; import appland.index.AppMapSearchScopes; import appland.installGuide.InstallGuideViewPage; @@ -11,7 +12,6 @@ import appland.settings.AppMapSettingsListener; import appland.toolwindow.AppMapContentPanel; import com.intellij.openapi.Disposable; -import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.application.ModalityState; import com.intellij.openapi.application.ReadAction; import com.intellij.openapi.project.Project; @@ -19,10 +19,12 @@ import com.intellij.util.Alarm; import com.intellij.util.SingleAlarm; import com.intellij.util.concurrency.AppExecutorUtil; +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread; import org.jetbrains.annotations.NotNull; import java.util.Arrays; import java.util.List; +import java.util.function.Function; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -35,7 +37,7 @@ public class InstallGuidePanel extends AppMapContentPanel implements Disposable private final Project project; private final List<StatusLabel> statusLabels; // debounce label updates by 500ms - private final SingleAlarm labelRefreshAlarm = new SingleAlarm(this::refreshInitialStatus, 500, this, Alarm.ThreadToUse.SWING_THREAD); + private final SingleAlarm labelRefreshAlarm = new SingleAlarm(this::refreshInitialStatus, 500, this, Alarm.ThreadToUse.POOLED_THREAD); public InstallGuidePanel(@NotNull Project project, @NotNull Disposable parent) { super(false); @@ -48,92 +50,107 @@ public InstallGuidePanel(@NotNull Project project, @NotNull Disposable parent) { setupPanel(); } + @Override + public void dispose() { + } + @Override protected void setupPanel() { statusLabels.forEach(this::add); - refreshInitialStatus(); registerStatusUpdateListeners(project, this, statusLabels); + labelRefreshAlarm.request(true); } private void triggerLabelStatusUpdate() { labelRefreshAlarm.cancelAndRequest(); } + @RequiresBackgroundThread private void refreshInitialStatus() { - for (var label : statusLabels) { - updateLabelStatus(project, label); - } + // Force a refresh of the metadata outside the non-blocking ReadAction below + // The refresh must not be executed within a ReadAction. + ProjectDataService.getInstance(project).getAppMapProjects(true); + + ReadAction.nonBlocking(() -> { + return statusLabels.stream().collect(Collectors.toMap(Function.identity(), label -> { + return updateLabelStatus(project, label); + })); + }) + .coalesceBy(this) + .expireWith(this) + .inSmartMode(project) + .finishOnUiThread(ModalityState.any(), statusMapping -> { + for (var entry : statusMapping.entrySet()) { + entry.getKey().setStatus(entry.getValue()); + } + } + ) + .submit(AppExecutorUtil.getAppExecutorService()); } /** - * if findings are enabled, completed if at least one appmap-findings.json file was found + * @param project Current project + * @param label Label to check + * @return {@code true} if the label should be enabled, {@code false} if it should be disabled */ - private static void updateRuntimeAnalysisLabel(@NotNull Project project, @NotNull StatusLabel label) { - var hasFindings = AppMapProjectSettingsService.getState(project).isInvestigatedFindings(); - label.setStatus(hasFindings ? InstallGuideStatus.Completed : InstallGuideStatus.Incomplete); - } - - private void updateLabelStatus(@NotNull Project project, @NotNull StatusLabel label) { + private @NotNull InstallGuideStatus updateLabelStatus(@NotNull Project project, @NotNull StatusLabel label) { switch (label.getPage()) { case InstallAgent: - updateInstallAgentLabel(project, label); - break; - + return updateInstallAgentLabel(project); case RecordAppMaps: - updateRecordAppMapsLabel(project, label); - break; - + return updateRecordAppMapsLabel(project); case OpenAppMaps: - updateOpenAppMapsLabel(project, label); - break; - + return updateOpenAppMapsLabel(project); case RuntimeAnalysis: - updateRuntimeAnalysisLabel(project, label); - break; + return updateRuntimeAnalysisLabel(project); + default: + throw new IllegalStateException("unexpected label: " + label); } } - @Override - public void dispose() { - } - /** * Presence of at least one appmap.yml file. */ - private static void updateInstallAgentLabel(@NotNull Project project, @NotNull StatusLabel label) { - ApplicationManager.getApplication().executeOnPooledThread(() -> { - // Always mark supported Java projects as supported, but only if all modules are supported. - // isAgentInstalled is already checking for supported Java projects and the presence of appmap.yml. - var anySupported = ProjectDataService.getInstance(project).getAppMapProjects() - .stream() - .anyMatch(ProjectMetadata::isAgentInstalled); - - ApplicationManager.getApplication().invokeLater(() -> { - label.setStatus(anySupported ? InstallGuideStatus.Completed : InstallGuideStatus.Incomplete); - }); - }); + private static @NotNull InstallGuideStatus updateInstallAgentLabel(@NotNull Project project) { + // Always mark supported Java projects as supported, but only if all modules are supported. + // isAgentInstalled is already checking for supported Java projects and the presence of appmap.yml. + var anySupported = ProjectDataService.getInstance(project).getAppMapProjects(false) + .stream() + .anyMatch(ProjectMetadata::isAgentInstalled); + return anySupported ? InstallGuideStatus.Completed : InstallGuideStatus.Incomplete; } /** - * presence of at least one .appmap.json file + * Presence of at least one .appmap.json file. */ - private static void updateRecordAppMapsLabel(@NotNull Project project, @NotNull StatusLabel label) { - findIndexedStatus(project, label, () -> !AppMapNameIndex.isEmpty(project, AppMapSearchScopes.appMapsWithExcluded(project))); + private static @NotNull InstallGuideStatus updateRecordAppMapsLabel(@NotNull Project project) { + return !AppMapNameIndex.isEmpty(project, AppMapSearchScopes.appMapsWithExcluded(project)) + ? InstallGuideStatus.Completed + : InstallGuideStatus.Incomplete; } /** - * if the AppMap webview was at least shown once + * If the AppMap webview was at least shown once */ - private static void updateOpenAppMapsLabel(@NotNull Project project, @NotNull StatusLabel label) { - label.setStatus(AppMapProjectSettingsService.getState(project).isOpenedAppMapEditor() + private static @NotNull InstallGuideStatus updateOpenAppMapsLabel(@NotNull Project project) { + return AppMapProjectSettingsService.getState(project).isOpenedAppMapEditor() ? InstallGuideStatus.Completed - : InstallGuideStatus.Incomplete); + : InstallGuideStatus.Incomplete; } - private static void updateGenerateOpenApiLabel(@NotNull Project project, @NotNull StatusLabel label) { - label.setStatus(AppMapProjectSettingsService.getState(project).isCreatedOpenAPI() + /** + * The label is updated under the following conditions: + * - The user navigated to the "Runtime Analysis" step in the installation guide webview + * - AppMaps have been opened + * - There are AppMaps in the project, and they have been scanned, regardless if findings were detected + */ + private static @NotNull InstallGuideStatus updateRuntimeAnalysisLabel(@NotNull Project project) { + var navigated = AppMapProjectSettingsService.getState(project).isInvestigatedFindings(); + var openedAppMaps = AppMapProjectSettingsService.getState(project).isOpenedAppMapEditor(); + var scannedAppMaps = AppMapFindingsUtil.isAnalysisPerformed(AppMapSearchScopes.projectFilesWithExcluded(project)); + return navigated && openedAppMaps && scannedAppMaps ? InstallGuideStatus.Completed - : InstallGuideStatus.Incomplete); + : InstallGuideStatus.Incomplete; } private void registerStatusUpdateListeners(@NotNull Project project, diff --git a/plugin-core/src/test/java/appland/installGuide/VSCodeFixturesTest.java b/plugin-core/src/test/java/appland/installGuide/VSCodeFixturesTest.java index 5d901e81..c5c1b85d 100644 --- a/plugin-core/src/test/java/appland/installGuide/VSCodeFixturesTest.java +++ b/plugin-core/src/test/java/appland/installGuide/VSCodeFixturesTest.java @@ -22,7 +22,7 @@ public void projectA() { // copy into src, which is the only content root of the test project WriteAction.runAndWait(() -> myFixture.copyDirectoryToProject("project-a", "")); - var projects = ProjectDataService.getInstance(getProject()).getAppMapProjects(); + var projects = ProjectDataService.getInstance(getProject()).getAppMapProjects(true); Assert.assertEquals(1, projects.size()); var projectA = projects.get(0); diff --git a/plugin-core/src/test/java/appland/installGuide/projectData/DefaultProjectDataServiceTest.java b/plugin-core/src/test/java/appland/installGuide/projectData/DefaultProjectDataServiceTest.java index 3d1d4e0f..73a33337 100644 --- a/plugin-core/src/test/java/appland/installGuide/projectData/DefaultProjectDataServiceTest.java +++ b/plugin-core/src/test/java/appland/installGuide/projectData/DefaultProjectDataServiceTest.java @@ -14,7 +14,7 @@ protected boolean runInDispatchThread() { public void selectedSampleObjects() { WriteAction.runAndWait(() -> myFixture.copyDirectoryToProject("appmap-scanner/with_findings", "root")); - var projects = ProjectDataService.getInstance(getProject()).getAppMapProjects(); + var projects = ProjectDataService.getInstance(getProject()).getAppMapProjects(true); assertEquals(1, projects.size()); var samples = projects.get(0).sampleCodeObjects; @@ -43,7 +43,7 @@ public void selectedSampleObjects() { public void truncatedSampleObjects() { WriteAction.runAndWait(() -> myFixture.copyDirectoryToProject("appmap-scanner/untruncated_findings", "root")); - var projects = ProjectDataService.getInstance(getProject()).getAppMapProjects(); + var projects = ProjectDataService.getInstance(getProject()).getAppMapProjects(true); assertEquals(1, projects.size()); var samples = projects.get(0).sampleCodeObjects;