diff --git a/src/main/java/org/sonarlint/intellij/SonarLintIntelliJClient.kt b/src/main/java/org/sonarlint/intellij/SonarLintIntelliJClient.kt index 32fad3f7d9..04eb2c2b8e 100644 --- a/src/main/java/org/sonarlint/intellij/SonarLintIntelliJClient.kt +++ b/src/main/java/org/sonarlint/intellij/SonarLintIntelliJClient.kt @@ -76,6 +76,7 @@ import org.sonarlint.intellij.analysis.AnalysisSubmitter.Companion.collectContri import org.sonarlint.intellij.analysis.InferredAnalysisPropertiesProvider.collectContributedExtraProperties import org.sonarlint.intellij.analysis.InferredAnalysisPropertiesProvider.getConfigurationFromConfiguratorEP import org.sonarlint.intellij.analysis.LocalFileExclusions +import org.sonarlint.intellij.analysis.OnTheFlyFindingsCoordinator import org.sonarlint.intellij.analysis.OpenInIdeFindingCache import org.sonarlint.intellij.analysis.RunningAnalysesTracker import org.sonarlint.intellij.binding.BindingSuggestionHandler.findOverriddenModules @@ -103,6 +104,7 @@ import org.sonarlint.intellij.documentation.SonarLintDocumentation.Intellij.CONN import org.sonarlint.intellij.documentation.SonarLintDocumentation.Intellij.CONNECTED_MODE_SETUP_LINK import org.sonarlint.intellij.documentation.SonarLintDocumentation.Intellij.SUPPORT_POLICY_LINK import org.sonarlint.intellij.documentation.SonarLintDocumentation.Intellij.TROUBLESHOOTING_CONNECTED_MODE_SETUP_LINK +import org.sonarlint.intellij.editor.EditorHighlightRefresh import org.sonarlint.intellij.finding.Finding import org.sonarlint.intellij.finding.ShowFinding import org.sonarlint.intellij.finding.hotspot.LiveSecurityHotspot @@ -925,7 +927,7 @@ object SonarLintIntelliJClient : SonarLintRpcClientDelegate { } ?: return getService(project, TaintVulnerabilitiesCache::class.java) .update(closedTaintVulnerabilityIds, locallyMatchedAddedTaintVulnerabilities, locallyMatchedUpdatedTaintVulnerabilities) - getService(project, SonarLintToolWindow::class.java).refreshViews() + getService(project, OnTheFlyFindingsCoordinator::class.java).applyHighlightRefreshAndRefreshPanels(EditorHighlightRefresh.enabled()) } override fun didChangeDependencyRisks( @@ -938,7 +940,7 @@ object SonarLintIntelliJClient : SonarLintRpcClientDelegate { val added = addedDependencyRisks.map { LocalDependencyRisk(it) } val updated = updatedDependencyRisks.map { LocalDependencyRisk(it) } getService(project, DependencyRisksCache::class.java).update(closedDependencyRiskIds, added, updated) - getService(project, SonarLintToolWindow::class.java).refreshViews() + getService(project, OnTheFlyFindingsCoordinator::class.java).applyHighlightRefreshAndRefreshPanels(EditorHighlightRefresh.enabled()) } override fun raiseIssues( diff --git a/src/main/java/org/sonarlint/intellij/actions/MarkAsResolvedAction.kt b/src/main/java/org/sonarlint/intellij/actions/MarkAsResolvedAction.kt index d21364fb68..cd66206786 100644 --- a/src/main/java/org/sonarlint/intellij/actions/MarkAsResolvedAction.kt +++ b/src/main/java/org/sonarlint/intellij/actions/MarkAsResolvedAction.kt @@ -32,11 +32,13 @@ import com.intellij.openapi.ui.DoNotAskOption import com.intellij.openapi.ui.MessageDialogBuilder import com.intellij.openapi.util.Iconable import com.intellij.psi.PsiFile +import org.sonarlint.intellij.analysis.OnTheFlyFindingsCoordinator import org.sonarlint.intellij.common.ui.SonarLintConsole import org.sonarlint.intellij.common.util.SonarLintUtils.getService import org.sonarlint.intellij.config.global.ServerConnection import org.sonarlint.intellij.core.BackendService import org.sonarlint.intellij.core.ProjectBindingManager +import org.sonarlint.intellij.editor.EditorHighlightRefresh import org.sonarlint.intellij.finding.Issue import org.sonarlint.intellij.finding.issue.vulnerabilities.LocalTaintVulnerability import org.sonarlint.intellij.notifications.SonarLintProjectNotifications @@ -149,7 +151,7 @@ class MarkAsResolvedAction( private fun updateUI(project: Project, issue: Issue) { issue.resolve() - getService(project, SonarLintToolWindow::class.java).refreshViews() + getService(project, OnTheFlyFindingsCoordinator::class.java).applyHighlightRefreshAndRefreshPanels(EditorHighlightRefresh.enabled()) } private fun addComment(project: Project, module: Module, issueKey: String, comment: String) { diff --git a/src/main/java/org/sonarlint/intellij/actions/ReopenIssueAction.kt b/src/main/java/org/sonarlint/intellij/actions/ReopenIssueAction.kt index 2498779201..0cdb0dde9d 100644 --- a/src/main/java/org/sonarlint/intellij/actions/ReopenIssueAction.kt +++ b/src/main/java/org/sonarlint/intellij/actions/ReopenIssueAction.kt @@ -32,12 +32,14 @@ import com.intellij.openapi.ui.MessageDialogBuilder import com.intellij.openapi.util.Iconable import com.intellij.psi.PsiFile import org.sonarlint.intellij.actions.MarkAsResolvedAction.Companion.REVIEW_ISSUE_GROUP +import org.sonarlint.intellij.analysis.OnTheFlyFindingsCoordinator import org.sonarlint.intellij.common.ui.SonarLintConsole import org.sonarlint.intellij.common.util.SonarLintUtils import org.sonarlint.intellij.common.util.SonarLintUtils.getService import org.sonarlint.intellij.config.global.ServerConnection import org.sonarlint.intellij.core.BackendService import org.sonarlint.intellij.core.ProjectBindingManager +import org.sonarlint.intellij.editor.EditorHighlightRefresh import org.sonarlint.intellij.finding.Issue import org.sonarlint.intellij.finding.issue.LiveIssue import org.sonarlint.intellij.finding.issue.vulnerabilities.LocalTaintVulnerability @@ -104,7 +106,7 @@ class ReopenIssueAction(private var issue: LiveIssue? = null) : AbstractSonarAct private fun updateUI(project: Project, issue: Issue) { issue.reopen() - getService(project, SonarLintToolWindow::class.java).refreshViews() + getService(project, OnTheFlyFindingsCoordinator::class.java).applyHighlightRefreshAndRefreshPanels(EditorHighlightRefresh.enabled()) } private fun confirm(project: Project, productName: String): Boolean { diff --git a/src/main/java/org/sonarlint/intellij/actions/SonarLintToolWindow.java b/src/main/java/org/sonarlint/intellij/actions/SonarLintToolWindow.java index 70c1bd99f8..c83b0dba8e 100644 --- a/src/main/java/org/sonarlint/intellij/actions/SonarLintToolWindow.java +++ b/src/main/java/org/sonarlint/intellij/actions/SonarLintToolWindow.java @@ -31,6 +31,7 @@ import javax.annotation.Nullable; import javax.swing.SwingUtilities; import org.sonarlint.intellij.analysis.AnalysisResult; +import org.sonarlint.intellij.analysis.OnTheFlyFindingsCoordinator; import org.sonarlint.intellij.editor.EditorHighlightRefresh; import org.sonarlint.intellij.finding.Finding; import org.sonarlint.intellij.finding.ShowFinding; @@ -40,6 +41,11 @@ import org.sonarlint.intellij.notifications.SonarLintProjectNotifications; import org.sonarlint.intellij.ui.ToolWindowConstants; import org.sonarlint.intellij.ui.currentfile.CurrentFilePanel; +import org.sonarlint.intellij.ui.filter.FilterCriteria; +import org.sonarlint.intellij.ui.filter.FilterSettingsService; +import org.sonarlint.intellij.ui.filter.SeverityFilter; +import org.sonarlint.intellij.ui.filter.SeverityImpactFilter; +import org.sonarlint.intellij.ui.filter.StatusFilter; import org.sonarlint.intellij.ui.report.ReportPanel; import org.sonarlint.intellij.ui.report.ReportTabManager; @@ -117,11 +123,7 @@ public void openLogTab() { } public void refreshViews() { - refreshViews(EditorHighlightRefresh.enabled()); - } - - public void refreshViews(EditorHighlightRefresh highlightRefresh) { - this.updateCurrentFileTab(panel -> panel.refreshView(highlightRefresh)); + this.updateCurrentFileTab(panel -> panel.refreshView(EditorHighlightRefresh.enabled())); var toolWindow = getToolWindow(); if (toolWindow != null) { runOnUiThread(project, () -> { @@ -163,9 +165,32 @@ private static void selectTab(ToolWindow toolWindow, String tabId) { } } - public void updateCurrentFileTab(@Nullable VirtualFile selectedFile, EditorHighlightRefresh highlightRefresh) { + + public FilterCriteria getCurrentFileFilterCriteria() { + var toolWindow = getToolWindow(); + if (toolWindow != null) { + var content = toolWindow.getContentManager().findContent(CURRENT_FILE_TAB_TITLE); + if (content != null && content.getComponent() instanceof CurrentFilePanel panel) { + return panel.getCurrentFilterCriteria(); + } + } + return getDefaultCurrentFileFilterCriteria(); + } + + private FilterCriteria getDefaultCurrentFileFilterCriteria() { + return new FilterCriteria( + new SeverityImpactFilter.Severity(SeverityFilter.NO_FILTER), + StatusFilter.OPEN, + "", + false, + false, + getService(FilterSettingsService.class).getDefaultFindingsScope() + ); + } + + public void updateCurrentFileTab(@Nullable VirtualFile selectedFile) { this.updateCurrentFileTab( - panel -> runOnUiThread(project, () -> panel.update(selectedFile, highlightRefresh))); + panel -> runOnUiThread(project, () -> panel.update(selectedFile, EditorHighlightRefresh.NONE))); } public void showFindingDescription(Finding liveIssue) { @@ -321,7 +346,7 @@ public void bringToFront() { @Override public void bindingChanged() { - refreshViews(); + getService(project, OnTheFlyFindingsCoordinator.class).applyHighlightRefreshAndRefreshPanels(EditorHighlightRefresh.enabled()); } } diff --git a/src/main/java/org/sonarlint/intellij/analysis/AnalysisState.kt b/src/main/java/org/sonarlint/intellij/analysis/AnalysisState.kt index 80caad157d..d61225c541 100644 --- a/src/main/java/org/sonarlint/intellij/analysis/AnalysisState.kt +++ b/src/main/java/org/sonarlint/intellij/analysis/AnalysisState.kt @@ -37,10 +37,11 @@ import org.sonarlint.intellij.util.VirtualFileUtils.uriToVirtualFile import org.sonarsource.sonarlint.core.rpc.protocol.client.hotspot.RaisedHotspotDto import org.sonarsource.sonarlint.core.rpc.protocol.client.issue.RaisedIssueDto -class AnalysisState( +class AnalysisState @JvmOverloads constructor( val id: UUID, private val analysisCallback: AnalysisCallback, - private val module: Module + private val module: Module, + private val submittedFiles: Collection = emptyList(), ) { private val modificationStampByFile = ConcurrentHashMap() private val analysisDate: Instant = Instant.now() @@ -63,19 +64,7 @@ class AnalysisState( } }) - if (isAnalysisFinished()) { - analysisCallback.onSuccess( - AnalysisResult( - analysisId, - LiveFindings(liveIssues, liveHotspots), - liveHotspots.keys, - analysisDate - ) - ) - getService(module.project, RunningAnalysesTracker::class.java).finish(this) - } else { - analysisCallback.onIntermediateResult(AnalysisIntermediateResult(LiveFindings(liveIssues, liveHotspots))) - } + notifyIfFinished(analysisId) } fun addRawIssues(analysisId: UUID, issuesByFile: Map>, isIntermediate: Boolean) { @@ -91,12 +80,16 @@ class AnalysisState( } }) + notifyIfFinished(analysisId) + } + + private fun notifyIfFinished(analysisId: UUID) { if (isAnalysisFinished()) { analysisCallback.onSuccess( AnalysisResult( analysisId, LiveFindings(liveIssues, liveHotspots), - liveIssues.keys, + analyzedFilesForResult(), analysisDate ) ) @@ -106,6 +99,13 @@ class AnalysisState( } } + /** + * Files this analysis actually covered. When the submitter did not pass a set (full-project / VCS-changed), + * fall back to the union of findings keys so report tabs keep today's behaviour. + */ + private fun analyzedFilesForResult(): Collection = + submittedFiles.ifEmpty { liveIssues.keys + liveHotspots.keys } + private fun convertRawHotspots(virtualFile: VirtualFile, rawHotspots: Collection): Collection { try { return rawHotspots.mapNotNull { hotspot -> diff --git a/src/main/java/org/sonarlint/intellij/analysis/AnalysisSubmitter.kt b/src/main/java/org/sonarlint/intellij/analysis/AnalysisSubmitter.kt index a6a0bbcff6..9ae93d01ab 100644 --- a/src/main/java/org/sonarlint/intellij/analysis/AnalysisSubmitter.kt +++ b/src/main/java/org/sonarlint/intellij/analysis/AnalysisSubmitter.kt @@ -22,6 +22,7 @@ package org.sonarlint.intellij.analysis import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service +import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.module.Module import com.intellij.openapi.module.ModuleManager import com.intellij.openapi.project.Project @@ -95,10 +96,12 @@ class AnalysisSubmitter(private val project: Project) { runOnPooledThread(project) { val callback = UpdateOnTheFlyFindingsCallable(onTheFlyFindingsHolder) val modules = ModuleManager.getInstance(project).modules + val openFiles = FileEditorManager.getInstance(project).openFiles.toList() modules.forEach { module -> getService(BackendService::class.java).analyzeOpenFiles(module).thenAccept { response -> response.analysisId?.let { analysisId -> - val analysisState = AnalysisState(analysisId, callback, module) + val moduleOpenFiles = openFiles.filter { findModuleForFile(it, project) == module } + val analysisState = AnalysisState(analysisId, callback, module, moduleOpenFiles) getService(project, RunningAnalysesTracker::class.java).track(analysisState) } } @@ -123,7 +126,7 @@ class AnalysisSubmitter(private val project: Project) { try { future[5, TimeUnit.SECONDS].analysisId?.let { analysisId -> console.debug("Pre-commit: analyzeFileList returned analysisId=$analysisId in ${System.currentTimeMillis() - startTime} ms") - getService(project, RunningAnalysesTracker::class.java).track(AnalysisState(analysisId, callback, module)) + getService(project, RunningAnalysesTracker::class.java).track(AnalysisState(analysisId, callback, module, files)) analysisIds.add(analysisId) } ?: console.debug("Pre-commit: analyzeFileList returned no analysisId in ${System.currentTimeMillis() - startTime} ms") } catch (e: TimeoutException) { @@ -190,7 +193,7 @@ class AnalysisSubmitter(private val project: Project) { .thenAccept { response -> response.analysisId?.let { analysisId -> getService(project, AnalysisStatus::class.java).tryRun(analysisId) - val analysisState = AnalysisState(analysisId, callback, module) + val analysisState = AnalysisState(analysisId, callback, module, files) getService(project, RunningAnalysesTracker::class.java).track(analysisState) } taskState?.trackTask(module, response.analysisId?.toString()) @@ -212,7 +215,7 @@ class AnalysisSubmitter(private val project: Project) { response.analysisId?.let { analysisId -> getService(project, OpenInIdeFindingCache::class.java).finding = null getService(project, OpenInIdeFindingCache::class.java).analysisQueued = false - val analysisState = AnalysisState(analysisId, callback, module) + val analysisState = AnalysisState(analysisId, callback, module, listOf(showFinding.file)) getService(project, RunningAnalysesTracker::class.java).track(analysisState) } } diff --git a/src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsCoordinator.kt b/src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsCoordinator.kt new file mode 100644 index 0000000000..4dcce5b1e3 --- /dev/null +++ b/src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsCoordinator.kt @@ -0,0 +1,79 @@ +/* + * SonarLint for IntelliJ IDEA + * Copyright (C) SonarSource Sàrl + * sonarlint@sonarsource.com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 + */ +package org.sonarlint.intellij.analysis + +import com.intellij.openapi.components.Service +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import org.sonarlint.intellij.actions.SonarLintToolWindow +import org.sonarlint.intellij.common.util.SonarLintUtils +import org.sonarlint.intellij.common.util.SonarLintUtils.getService +import org.sonarlint.intellij.editor.CodeAnalyzerRestarter +import org.sonarlint.intellij.editor.EditorHighlightRefresh +import org.sonarlint.intellij.ui.currentfile.CurrentFileDisplayedFindingsRefresher + +/** + * Resolves an [EditorHighlightRefresh] into a set of files, refreshes the filtered findings snapshot used by + * [org.sonarlint.intellij.editor.DirectHighlighter], and triggers editor markup refresh independently of the + * Current File tool window UI lifecycle. + * + * The [OnTheFlyFindingsHolder] owns the unfiltered per-open-file maps. Editor squiggles follow the Current File tab + * filters via [org.sonarlint.intellij.ui.currentfile.CurrentFileDisplayedFindingsStore]. [SonarLintToolWindow.refreshViews] + * rebuilds panels only; callers that need markup plus panels use [applyHighlightRefreshAndRefreshPanels]. + */ +@Service(Service.Level.PROJECT) +class OnTheFlyFindingsCoordinator(private val project: Project) { + + fun applyHighlightRefresh(highlightRefresh: EditorHighlightRefresh) { + if (!highlightRefresh.enabled || project.isDisposed) { + return + } + val files = resolveFiles(highlightRefresh).filter { it.isValid } + if (files.isEmpty()) { + return + } + getService(project, CurrentFileDisplayedFindingsRefresher::class.java) + .refreshDisplayedFindings(SonarLintUtils.getSelectedFile(project)) + getService(project, CodeAnalyzerRestarter::class.java).refreshFiles(files) + } + + /** + * Refreshes editor markup, then rebuilds tool-window panels. Use when findings caches changed outside the + * holder's analysis publish path (taints, CAYC, resolve actions, binding changes). + */ + fun applyHighlightRefreshAndRefreshPanels(highlightRefresh: EditorHighlightRefresh) { + applyHighlightRefresh(highlightRefresh) + if (!project.isDisposed) { + getService(project, SonarLintToolWindow::class.java).refreshViews() + } + } + + private fun resolveFiles(highlightRefresh: EditorHighlightRefresh): Collection { + return when { + highlightRefresh.allOpenFiles -> openEditors() + highlightRefresh.changedFiles != null -> highlightRefresh.changedFiles + else -> openEditors() + } + } + + private fun openEditors(): List = + FileEditorManager.getInstance(project).openFiles.toList() +} diff --git a/src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsHolder.kt b/src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsHolder.kt index 94e4849c5d..8a0685b8d2 100644 --- a/src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsHolder.kt +++ b/src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsHolder.kt @@ -27,6 +27,7 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import java.net.URI import java.util.concurrent.ConcurrentHashMap +import org.jetbrains.annotations.VisibleForTesting import org.sonarlint.intellij.actions.SonarLintToolWindow import org.sonarlint.intellij.common.util.SonarLintUtils import org.sonarlint.intellij.common.util.SonarLintUtils.getService @@ -54,35 +55,33 @@ class OnTheFlyFindingsHolder(private val project: Project) : FileEditorManagerLi } fun updateOnAnalysisResult(analysisResult: AnalysisResult) = - updateViewsWithNewFindings(analysisResult.findings, refreshHighlights = true) + updateViewsWithNewFindings(analysisResult.findings, refreshHighlights = true, analyzedFiles = analysisResult.analyzedFiles) fun updateOnAnalysisIntermediateResult(intermediateResult: AnalysisIntermediateResult) = - updateViewsWithNewFindings(intermediateResult.findings, refreshHighlights = false) - - private fun updateViewsWithNewFindings(findings: LiveFindings, refreshHighlights: Boolean) { - ensureSelectedFileIsSet() - // Temporary workaround as FileEditorManager.openFiles does not return open files on dev containers/SSH - val openedFiles = openFiles.ifEmpty { setOfNotNull(selectedFile) } + updateViewsWithNewFindings(intermediateResult.findings, refreshHighlights = false, analyzedFiles = null) + + private fun updateViewsWithNewFindings( + findings: LiveFindings, + refreshHighlights: Boolean, + analyzedFiles: Collection?, + ) { + val openedFiles = openOpenFiles() val filteredFindings = findings.onlyFor(openedFiles) - // Derive changed files from the set of analyzed files, not only files that - // still have findings, so cleared files get their highlights removed. - val previouslyHighlightedOpenFiles = currentIssuesPerOpenFile.keys + currentSecurityHotspotsPerOpenFile.keys - with(filteredFindings) { - currentIssuesPerOpenFile.putAll(issuesPerFile) - currentSecurityHotspotsPerOpenFile.putAll(securityHotspotsPerFile) - } - val changedFiles = (filteredFindings.filesInvolved + previouslyHighlightedOpenFiles).intersect(openedFiles) - publishViewUpdate( - highlightRefresh = if (refreshHighlights) EditorHighlightRefresh.enabled(changedFiles) else EditorHighlightRefresh.NONE, - // Security hotspots live in their own tab, so a full refresh is required to keep it in sync. + commitOpenFileFindings( + openedFiles = openedFiles, + issuesByFile = filteredFindings.issuesPerFile, + hotspotsByFile = filteredFindings.securityHotspotsPerFile, + analyzedOpenFilesForEmptyClear = analyzedFiles?.filter { it in openedFiles }?.toSet(), + refreshHighlights = refreshHighlights, forceFullPanelRefresh = filteredFindings.securityHotspotsPerFile.isNotEmpty(), ) } fun updateViewsWithNewIssues(module: Module, raisedIssues: Map>, isIntermediate: Boolean = false) { + val openedFiles = openOpenFiles() val issues = raisedIssues.mapNotNull { (uri, rawIssues) -> val virtualFile = uriToVirtualFile(uri) ?: return@mapNotNull null - if (virtualFile in openFiles || virtualFile == selectedFile) { + if (virtualFile in openedFiles) { val liveIssues = rawIssues.mapNotNull { RawIssueAdapter.toLiveIssue(module, it, virtualFile, null) } @@ -92,15 +91,18 @@ class OnTheFlyFindingsHolder(private val project: Project) : FileEditorManagerLi } }.toMap() - currentIssuesPerOpenFile.putAll(issues) - ensureSelectedFileIsSet() - publishViewUpdate(if (isIntermediate) EditorHighlightRefresh.NONE else EditorHighlightRefresh.enabled(issues.keys)) + commitOpenFileFindings( + openedFiles = openedFiles, + issuesByFile = issues, + refreshHighlights = !isIntermediate, + ) } fun updateViewsWithNewSecurityHotspots(module: Module, raisedSecurityHotspots: Map>, isIntermediate: Boolean = false) { + val openedFiles = openOpenFiles() val securityHotspots = raisedSecurityHotspots.mapNotNull { (uri, rawSecurityHotspots) -> val virtualFile = uriToVirtualFile(uri) ?: return@mapNotNull null - if (virtualFile in openFiles || virtualFile == selectedFile) { + if (virtualFile in openedFiles) { val liveHotspots = rawSecurityHotspots.mapNotNull { RawIssueAdapter.toLiveSecurityHotspot(module, it, virtualFile, null) } @@ -110,10 +112,10 @@ class OnTheFlyFindingsHolder(private val project: Project) : FileEditorManagerLi } }.toMap() - currentSecurityHotspotsPerOpenFile.putAll(securityHotspots) - ensureSelectedFileIsSet() - publishViewUpdate( - highlightRefresh = if (isIntermediate) EditorHighlightRefresh.NONE else EditorHighlightRefresh.enabled(securityHotspots.keys), + commitOpenFileFindings( + openedFiles = openedFiles, + hotspotsByFile = securityHotspots, + refreshHighlights = !isIntermediate, forceFullPanelRefresh = true, ) } @@ -121,7 +123,7 @@ class OnTheFlyFindingsHolder(private val project: Project) : FileEditorManagerLi override fun selectionChanged(event: FileEditorManagerEvent) { selectedFile = event.newFile // Re-highlight the newly selected file: its findings may already be known but not yet drawn in this editor. - updateCurrentFileTab(EditorHighlightRefresh.enabled()) + updateCurrentFileTab(EditorHighlightRefresh.enabled(listOfNotNull(selectedFile))) } override fun fileClosed(source: FileEditorManager, file: VirtualFile) { @@ -159,6 +161,11 @@ class OnTheFlyFindingsHolder(private val project: Project) : FileEditorManagerLi return currentSecurityHotspotsPerOpenFile[file] ?: emptyList() } + @VisibleForTesting + fun replaceIssuesForFile(file: VirtualFile, issues: Collection) { + currentIssuesPerOpenFile[file] = issues + } + fun clearAllCurrentFileFindings() { currentIssuesPerOpenFile.clear() currentSecurityHotspotsPerOpenFile.clear() @@ -174,33 +181,70 @@ class OnTheFlyFindingsHolder(private val project: Project) : FileEditorManagerLi } } + private fun openOpenFiles(): Set { + ensureSelectedFileIsSet() + // Temporary workaround as FileEditorManager.openFiles does not return open files on dev containers/SSH + return openFiles.ifEmpty { setOfNotNull(selectedFile) } + } + + private fun commitOpenFileFindings( + openedFiles: Set = openOpenFiles(), + issuesByFile: Map> = emptyMap(), + hotspotsByFile: Map> = emptyMap(), + analyzedOpenFilesForEmptyClear: Set? = null, + refreshHighlights: Boolean = true, + forceFullPanelRefresh: Boolean = false, + ) { + issuesByFile.forEach { (file, issues) -> + if (file in openedFiles) { + currentIssuesPerOpenFile[file] = issues + } + } + hotspotsByFile.forEach { (file, hotspots) -> + if (file in openedFiles) { + currentSecurityHotspotsPerOpenFile[file] = hotspots + } + } + val analyzedOpenFiles = analyzedOpenFilesForEmptyClear.orEmpty() + analyzedOpenFiles.forEach { file -> + if (file !in issuesByFile) { + currentIssuesPerOpenFile[file] = emptyList() + } + if (file !in hotspotsByFile) { + currentSecurityHotspotsPerOpenFile[file] = emptyList() + } + } + val changedFiles = issuesByFile.keys + hotspotsByFile.keys + analyzedOpenFiles + publishViewUpdate( + highlightRefresh = if (refreshHighlights) EditorHighlightRefresh.enabled(changedFiles) else EditorHighlightRefresh.NONE, + forceFullPanelRefresh = forceFullPanelRefresh, + ) + } + /** * Pushes the current findings to the tool window. When [forceFullPanelRefresh] is set, all tabs (including the - * Security Hotspots tab) are rebuilt; otherwise only the Current File tab is updated. [highlightRefresh] controls - * whether - and for which editors - the on-the-fly highlights are recomputed. + * Security Hotspots tab) are rebuilt; otherwise only the Current File tab is updated. [highlightRefresh] is applied + * by the coordinator before any tool-window update, so markup does not wait on the Current File tab. */ private fun publishViewUpdate(highlightRefresh: EditorHighlightRefresh, forceFullPanelRefresh: Boolean = false) { if (project.isDisposed) { return } + getService(project, OnTheFlyFindingsCoordinator::class.java).applyHighlightRefresh(highlightRefresh) val toolWindow = getService(project, SonarLintToolWindow::class.java) if (forceFullPanelRefresh) { - toolWindow.refreshViews(highlightRefresh) + toolWindow.refreshViews() } else { - toolWindow.updateCurrentFileTab(selectedFile, highlightRefresh) + toolWindow.updateCurrentFileTab(selectedFile) } } private fun refreshViews(highlightRefresh: EditorHighlightRefresh) { - if (!project.isDisposed) { - getService(project, SonarLintToolWindow::class.java).refreshViews(highlightRefresh) - } + publishViewUpdate(highlightRefresh, forceFullPanelRefresh = true) } private fun updateCurrentFileTab(highlightRefresh: EditorHighlightRefresh = EditorHighlightRefresh.NONE) { - if (!project.isDisposed) { - getService(project, SonarLintToolWindow::class.java).updateCurrentFileTab(selectedFile, highlightRefresh) - } + publishViewUpdate(highlightRefresh) } private val openFiles: Set diff --git a/src/main/java/org/sonarlint/intellij/cayc/CleanAsYouCodeService.kt b/src/main/java/org/sonarlint/intellij/cayc/CleanAsYouCodeService.kt index c8e085b568..b90957a907 100644 --- a/src/main/java/org/sonarlint/intellij/cayc/CleanAsYouCodeService.kt +++ b/src/main/java/org/sonarlint/intellij/cayc/CleanAsYouCodeService.kt @@ -21,11 +21,12 @@ package org.sonarlint.intellij.cayc import com.intellij.openapi.components.Service import com.intellij.openapi.project.ProjectManager -import org.sonarlint.intellij.actions.SonarLintToolWindow +import org.sonarlint.intellij.analysis.OnTheFlyFindingsCoordinator import org.sonarlint.intellij.common.util.SonarLintUtils.getService import org.sonarlint.intellij.config.Settings.getGlobalSettings import org.sonarlint.intellij.config.global.SonarLintGlobalSettings import org.sonarlint.intellij.core.BackendService +import org.sonarlint.intellij.editor.EditorHighlightRefresh import org.sonarlint.intellij.util.runOnPooledThread @Service(Service.Level.APP) @@ -49,7 +50,7 @@ class CleanAsYouCodeService { getService(BackendService::class.java).triggerTelemetryForFocusOnNewCode() ProjectManager.getInstance().openProjects.forEach { project -> if (!project.isDisposed) { - getService(project, SonarLintToolWindow::class.java).refreshViews() + getService(project, OnTheFlyFindingsCoordinator::class.java).applyHighlightRefreshAndRefreshPanels(EditorHighlightRefresh.enabled()) } } } diff --git a/src/main/java/org/sonarlint/intellij/core/BackendService.kt b/src/main/java/org/sonarlint/intellij/core/BackendService.kt index 1584e8648d..d70826966a 100644 --- a/src/main/java/org/sonarlint/intellij/core/BackendService.kt +++ b/src/main/java/org/sonarlint/intellij/core/BackendService.kt @@ -57,10 +57,10 @@ import org.sonarlint.intellij.SonarLintIntelliJClient import org.sonarlint.intellij.SonarLintPlugin import org.sonarlint.intellij.actions.RestartBackendAction.Companion.SONARLINT_ERROR_MSG import org.sonarlint.intellij.actions.RestartBackendNotificationAction -import org.sonarlint.intellij.actions.SonarLintToolWindow import org.sonarlint.intellij.analysis.AnalysisSubmitter import org.sonarlint.intellij.analysis.AnalysisSubmitter.Companion.collectContributedLanguages import org.sonarlint.intellij.analysis.GlobalBackgroundTaskTracker +import org.sonarlint.intellij.analysis.OnTheFlyFindingsCoordinator import org.sonarlint.intellij.common.ui.ReadActionUtils.Companion.computeReadActionSafely import org.sonarlint.intellij.common.ui.SonarLintConsole import org.sonarlint.intellij.common.util.SonarLintUtils.getService @@ -70,6 +70,7 @@ import org.sonarlint.intellij.config.global.NodeJsSettings import org.sonarlint.intellij.config.global.ServerConnection import org.sonarlint.intellij.config.global.SonarLintGlobalSettings import org.sonarlint.intellij.config.global.credentials.CredentialsService +import org.sonarlint.intellij.editor.EditorHighlightRefresh import org.sonarlint.intellij.finding.issue.vulnerabilities.TaintVulnerabilitiesCache import org.sonarlint.intellij.finding.issue.vulnerabilities.TaintVulnerabilityMatcher import org.sonarlint.intellij.fs.VirtualFileEvent @@ -323,7 +324,7 @@ class BackendService : Disposable { private fun handleSloopExited() { ProjectManager.getInstance().openProjects.forEach { project -> runOnUiThread(project) { - getService(project, SonarLintToolWindow::class.java).refreshViews() + getService(project, OnTheFlyFindingsCoordinator::class.java).applyHighlightRefreshAndRefreshPanels(EditorHighlightRefresh.enabled()) } } @@ -927,7 +928,7 @@ class BackendService : Disposable { private fun catchUpWithBackend(rpcServer: SonarLintRpcServer) { ProjectManager.getInstance().openProjects.forEach { project -> runOnUiThread(project) { - getService(project, SonarLintToolWindow::class.java).refreshViews() + getService(project, OnTheFlyFindingsCoordinator::class.java).applyHighlightRefreshAndRefreshPanels(EditorHighlightRefresh.enabled()) } val binding = getService(project, ProjectBindingManager::class.java).binding @@ -1000,7 +1001,7 @@ class BackendService : Disposable { } ?: return@thenApplyAsync getService(project, TaintVulnerabilitiesCache::class.java).taintVulnerabilities = localTaintVulnerabilities runOnUiThread(project) { - getService(project, SonarLintToolWindow::class.java).refreshViews() + getService(project, OnTheFlyFindingsCoordinator::class.java).applyHighlightRefreshAndRefreshPanels(EditorHighlightRefresh.enabled()) } } } diff --git a/src/main/java/org/sonarlint/intellij/editor/DirectHighlighter.kt b/src/main/java/org/sonarlint/intellij/editor/DirectHighlighter.kt index 8cd631d023..fd882f0197 100644 --- a/src/main/java/org/sonarlint/intellij/editor/DirectHighlighter.kt +++ b/src/main/java/org/sonarlint/intellij/editor/DirectHighlighter.kt @@ -238,9 +238,8 @@ class DirectHighlighter @NonInjectable internal constructor( ) /** - * Builds the list of highlights to render for [file] from the findings currently displayed in the tool window. - * Reads the shared snapshot rather than the raw analysis so that editor highlights always match what the user - * sees in the Current File tab (same filtering, same resolved/new-code handling). + * Builds the list of highlights to render for [file] from the filtered findings snapshot shown in the Current File + * tab. Resolved findings are still skipped, and CAYC styling is still applied. */ private fun collectHighlightPlans(file: VirtualFile): List { val findings = getService(project, CurrentFileDisplayedFindingsStore::class.java).getFindingsForFile(file) diff --git a/src/main/java/org/sonarlint/intellij/editor/EditorHighlightRefresh.kt b/src/main/java/org/sonarlint/intellij/editor/EditorHighlightRefresh.kt index b07d10fb73..b708b84e30 100644 --- a/src/main/java/org/sonarlint/intellij/editor/EditorHighlightRefresh.kt +++ b/src/main/java/org/sonarlint/intellij/editor/EditorHighlightRefresh.kt @@ -24,16 +24,15 @@ import org.sonarlint.intellij.editor.EditorHighlightRefresh.Companion.ALL_OPEN_F import org.sonarlint.intellij.editor.EditorHighlightRefresh.Companion.NONE /** - * Describes whether - and for which files - SonarQube editor highlights should be recomputed after the findings shown - * in the tool window change. + * Describes whether - and for which files - SonarQube editor highlights should be recomputed after on-the-fly + * findings change. * * This replaces the previous `(refreshEditorHighlights, highlightChangedFiles, highlightAllOpenFiles)` boolean triplet - * that was threaded through several layers. The concrete set of files is resolved lazily by the Current File panel - * because it depends on the active findings scope (see `CurrentFilePanel.resolveEditorHighlightFiles`): + * that was threaded through several layers. The concrete set of files is resolved by [org.sonarlint.intellij.analysis.OnTheFlyFindingsCoordinator], + * not the Current File panel, and [changedFiles] is not gated by findings scope: * - [enabled] `false` ([NONE]): editor highlights are left untouched (e.g. for intermediate analysis results). * - [allOpenFiles] `true` ([ALL_OPEN_FILES]): every open editor is refreshed (e.g. when all findings are cleared). - * - [changedFiles]: hint of the files whose findings changed. It is only honored in the "all files" scope; the - * "current file" scope always refreshes just the selected file, preserving the pre-existing behaviour. + * - [changedFiles]: files whose findings changed; honored whenever present. When null, every open editor is refreshed. */ data class EditorHighlightRefresh( val enabled: Boolean, @@ -49,7 +48,7 @@ data class EditorHighlightRefresh( @JvmField val ALL_OPEN_FILES = EditorHighlightRefresh(enabled = true, allOpenFiles = true) - /** Refresh highlights, resolving the affected files from [changedFiles] and the active findings scope. */ + /** Refresh highlights, resolving the affected files from [changedFiles] when present, otherwise all open editors. */ @JvmStatic @JvmOverloads fun enabled(changedFiles: Collection? = null) = diff --git a/src/main/java/org/sonarlint/intellij/ui/currentfile/CurrentFileDisplayedFindingsRefresher.kt b/src/main/java/org/sonarlint/intellij/ui/currentfile/CurrentFileDisplayedFindingsRefresher.kt new file mode 100644 index 0000000000..95856728f2 --- /dev/null +++ b/src/main/java/org/sonarlint/intellij/ui/currentfile/CurrentFileDisplayedFindingsRefresher.kt @@ -0,0 +1,43 @@ +/* + * SonarLint for IntelliJ IDEA + * Copyright (C) SonarSource Sàrl + * sonarlint@sonarsource.com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 + */ +package org.sonarlint.intellij.ui.currentfile + +import com.intellij.openapi.components.Service +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import org.sonarlint.intellij.actions.SonarLintToolWindow +import org.sonarlint.intellij.common.util.SonarLintUtils.getService +import org.sonarlint.intellij.ui.filter.FilterCriteria +import org.sonarlint.intellij.ui.filter.FindingsFilter + +/** + * Refreshes the filtered findings snapshot read by [org.sonarlint.intellij.editor.DirectHighlighter]. + * Independent of whether the Current File panel UI is visible or has run an update cycle. + */ +@Service(Service.Level.PROJECT) +class CurrentFileDisplayedFindingsRefresher(private val project: Project) { + + fun refreshDisplayedFindings(file: VirtualFile?) { + val criteria = getService(project, SonarLintToolWindow::class.java).getCurrentFileFilterCriteria() + ?: FilterCriteria() + val filteredFindings = FindingsFilter(project).filterAllFindings(file, criteria) + getService(project, CurrentFileDisplayedFindingsStore::class.java).setSnapshot(filteredFindings) + } +} diff --git a/src/main/java/org/sonarlint/intellij/ui/currentfile/CurrentFilePanel.kt b/src/main/java/org/sonarlint/intellij/ui/currentfile/CurrentFilePanel.kt index 08ef845c26..63abd1e431 100644 --- a/src/main/java/org/sonarlint/intellij/ui/currentfile/CurrentFilePanel.kt +++ b/src/main/java/org/sonarlint/intellij/ui/currentfile/CurrentFilePanel.kt @@ -37,12 +37,12 @@ import javax.swing.JScrollPane import javax.swing.tree.TreePath import org.sonarlint.intellij.actions.RestartBackendAction import org.sonarlint.intellij.analysis.AnalysisReadinessCache +import org.sonarlint.intellij.analysis.OnTheFlyFindingsCoordinator import org.sonarlint.intellij.cayc.CleanAsYouCodeService import org.sonarlint.intellij.common.util.SonarLintUtils.getService import org.sonarlint.intellij.config.Settings import org.sonarlint.intellij.config.global.SonarLintGlobalSettings import org.sonarlint.intellij.core.BackendService -import org.sonarlint.intellij.editor.CodeAnalyzerRestarter import org.sonarlint.intellij.editor.EditorHighlightRefresh import org.sonarlint.intellij.finding.Finding import org.sonarlint.intellij.finding.ShowFinding @@ -357,6 +357,8 @@ class CurrentFilePanel(project: Project) : CurrentFileFindingsPanel(project) { } } + fun getCurrentFilterCriteria(): FilterCriteria = displayManager.getCurrentFilterCriteria() + fun update(file: VirtualFile?, highlightRefresh: EditorHighlightRefresh = EditorHighlightRefresh.NONE) { this.currentFile = file @@ -364,20 +366,20 @@ class CurrentFilePanel(project: Project) : CurrentFileFindingsPanel(project) { val filterCriteria = displayManager.getCurrentFilterCriteria() try { - val fileChanged = file != lastFile - val filtersChanged = filterCriteria != lastFilterCriteria + val fileChanged = file != lastFile + val filtersChanged = filterCriteria != lastFilterCriteria - // Always check for new findings - they may have changed even with same file/filters - val newFilteredFindings = findingsFilter.filterAllFindings(file, filterCriteria) - val findingsChanged = newFilteredFindings != filteredFindingsCache + // Always check for new findings - they may have changed even with same file/filters + val newFilteredFindings = findingsFilter.filterAllFindings(file, filterCriteria) + val findingsChanged = newFilteredFindings != filteredFindingsCache - // Skip expensive operations only if truly nothing has changed - if (!fileChanged && !filtersChanged && !findingsChanged) { - return - } + // Skip expensive operations only if truly nothing has changed + if (!fileChanged && !filtersChanged && !findingsChanged) { + return + } - filteredFindingsCache = newFilteredFindings - getService(project, CurrentFileDisplayedFindingsStore::class.java).setSnapshot(newFilteredFindings) + filteredFindingsCache = newFilteredFindings + getService(project, CurrentFileDisplayedFindingsStore::class.java).setSnapshot(newFilteredFindings) // Cache values for next comparison lastFile = file @@ -437,8 +439,6 @@ class CurrentFilePanel(project: Project) : CurrentFileFindingsPanel(project) { expandTreesWithStatePreservation(treeStateSnapshot) updateSummaryButtons() } finally { - // Refresh highlights so they are updated even when the panel content is left - // unchanged by the early return above (e.g. on a selection change back to an already-populated file). if (highlightRefresh.enabled) { refreshEditorHighlights(file, filterCriteria, highlightRefresh) } @@ -448,17 +448,11 @@ class CurrentFilePanel(project: Project) : CurrentFileFindingsPanel(project) { private fun refreshEditorHighlights(file: VirtualFile?, filterCriteria: FilterCriteria, highlightRefresh: EditorHighlightRefresh) { val files = resolveEditorHighlightFiles(file, filterCriteria, highlightRefresh) if (files.isNotEmpty()) { - getService(project, CodeAnalyzerRestarter::class.java).refreshFiles(files) + getService(project, OnTheFlyFindingsCoordinator::class.java) + .applyHighlightRefresh(EditorHighlightRefresh.enabled(files)) } } - /** - * Resolves which open editors should have their highlights recomputed. - * - * Note the scope precedence: in the default [FindingsScope.CURRENT_FILE] view only the selected file is ever - * refreshed, so [EditorHighlightRefresh.changedFiles] is only consulted in the "all files" scope. This matches - * the historical behaviour where background editors kept their highlights until they were re-selected. - */ private fun resolveEditorHighlightFiles( file: VirtualFile?, filterCriteria: FilterCriteria, @@ -467,7 +461,7 @@ class CurrentFilePanel(project: Project) : CurrentFileFindingsPanel(project) { val files = when { highlightRefresh.allOpenFiles -> FileEditorManager.getInstance(project).openFiles.toList() filterCriteria.findingsScope == FindingsScope.CURRENT_FILE -> listOfNotNull(file) - highlightRefresh.changedFiles != null -> highlightRefresh.changedFiles.toList() + highlightRefresh.changedFiles != null -> highlightRefresh.changedFiles!!.toList() else -> FileEditorManager.getInstance(project).openFiles.toList() } return files.filter { it.isValid } diff --git a/src/main/java/org/sonarlint/intellij/ui/review/ReviewSecurityHotspotDialog.kt b/src/main/java/org/sonarlint/intellij/ui/review/ReviewSecurityHotspotDialog.kt index a2fbb9a022..892a302d95 100644 --- a/src/main/java/org/sonarlint/intellij/ui/review/ReviewSecurityHotspotDialog.kt +++ b/src/main/java/org/sonarlint/intellij/ui/review/ReviewSecurityHotspotDialog.kt @@ -28,11 +28,12 @@ import com.intellij.openapi.ui.DialogWrapper import java.awt.event.ActionEvent import javax.swing.JButton import org.sonarlint.intellij.actions.ReviewSecurityHotspotAction.Companion.REVIEW_HOTSPOT_GROUP -import org.sonarlint.intellij.actions.SonarLintToolWindow +import org.sonarlint.intellij.analysis.OnTheFlyFindingsCoordinator import org.sonarlint.intellij.common.ui.SonarLintConsole import org.sonarlint.intellij.common.util.SonarLintUtils import org.sonarlint.intellij.core.BackendService import org.sonarlint.intellij.documentation.SonarLintDocumentation.Intellij.SECURITY_HOTSPOTS_LINK +import org.sonarlint.intellij.editor.EditorHighlightRefresh import org.sonarlint.intellij.notifications.SonarLintProjectNotifications import org.sonarlint.intellij.ui.UiUtils.Companion.runOnUiThread import org.sonarlint.intellij.util.runOnPooledThread @@ -73,7 +74,7 @@ class ReviewSecurityHotspotDialog( SonarLintUtils.getService(BackendService::class.java) .changeStatusForHotspot(module, securityHotspotKey, status) .thenAcceptAsync { - SonarLintUtils.getService(project, SonarLintToolWindow::class.java).refreshViews() + SonarLintUtils.getService(project, OnTheFlyFindingsCoordinator::class.java).applyHighlightRefreshAndRefreshPanels(EditorHighlightRefresh.enabled()) runOnUiThread( project, modalityState, diff --git a/src/test/java/org/sonarlint/intellij/SonarLintIntelliJClientTests.kt b/src/test/java/org/sonarlint/intellij/SonarLintIntelliJClientTests.kt index 396f77c2e8..19531a9a5a 100644 --- a/src/test/java/org/sonarlint/intellij/SonarLintIntelliJClientTests.kt +++ b/src/test/java/org/sonarlint/intellij/SonarLintIntelliJClientTests.kt @@ -38,9 +38,10 @@ import org.mockito.kotlin.argThat import org.mockito.kotlin.eq import org.sonarlint.intellij.actions.OpenTrackedLinkAction import org.sonarlint.intellij.actions.RestartBackendNotificationAction -import org.sonarlint.intellij.actions.SonarLintToolWindow +import org.sonarlint.intellij.analysis.OnTheFlyFindingsCoordinator import org.sonarlint.intellij.config.global.ServerConnection import org.sonarlint.intellij.core.BackendService +import org.sonarlint.intellij.editor.EditorHighlightRefresh import org.sonarlint.intellij.finding.sca.DependencyRisksCache import org.sonarlint.intellij.finding.sca.aDependencyRiskDto import org.sonarlint.intellij.messages.PLUGIN_STATUS_CHANGE_TOPIC @@ -395,8 +396,8 @@ class SonarLintIntelliJClientTests : AbstractSonarLintLightTests() { @Test fun should_handle_dependency_risks_changes() { - val toolWindow = mock(SonarLintToolWindow::class.java) - replaceProjectService(SonarLintToolWindow::class.java, toolWindow) + val coordinator = mock(OnTheFlyFindingsCoordinator::class.java) + replaceProjectService(OnTheFlyFindingsCoordinator::class.java, coordinator) val risksCache = mock(DependencyRisksCache::class.java) replaceProjectService(DependencyRisksCache::class.java, risksCache) @@ -428,7 +429,7 @@ class SonarLintIntelliJClientTests : AbstractSonarLintLightTests() { updatedLocal[0].getId() == updatedRiskId1 && updatedLocal[0].isResolved() } ) - verify(toolWindow).refreshViews() + verify(coordinator).applyHighlightRefreshAndRefreshPanels(EditorHighlightRefresh.enabled()) } @Test @@ -442,15 +443,15 @@ class SonarLintIntelliJClientTests : AbstractSonarLintLightTests() { @Test fun should_handle_empty_dependency_risks_changes() { - val toolWindow = mock(SonarLintToolWindow::class.java) - replaceProjectService(SonarLintToolWindow::class.java, toolWindow) + val coordinator = mock(OnTheFlyFindingsCoordinator::class.java) + replaceProjectService(OnTheFlyFindingsCoordinator::class.java, coordinator) val risksCache = mock(DependencyRisksCache::class.java) replaceProjectService(DependencyRisksCache::class.java, risksCache) client.didChangeDependencyRisks(projectBackendId, emptySet(), emptyList(), emptyList()) verify(risksCache).update(emptySet(), emptyList(), emptyList()) - verify(toolWindow).refreshViews() + verify(coordinator).applyHighlightRefreshAndRefreshPanels(EditorHighlightRefresh.enabled()) } @Test diff --git a/src/test/java/org/sonarlint/intellij/analysis/AnalysisStateTests.kt b/src/test/java/org/sonarlint/intellij/analysis/AnalysisStateTests.kt new file mode 100644 index 0000000000..da05b58d12 --- /dev/null +++ b/src/test/java/org/sonarlint/intellij/analysis/AnalysisStateTests.kt @@ -0,0 +1,69 @@ +/* + * SonarLint for IntelliJ IDEA + * Copyright (C) SonarSource Sàrl + * sonarlint@sonarsource.com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 + */ +package org.sonarlint.intellij.analysis + +import java.util.UUID +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.check +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.sonarlint.intellij.AbstractSonarLintLightTests +import org.sonarlint.intellij.util.VirtualFileUtils + +class AnalysisStateTests : AbstractSonarLintLightTests() { + + @Test + fun should_put_submitted_files_on_the_result_even_when_findings_omit_them() { + val fileA = createAndOpenTestVirtualFile("A.java", "class A {}") + val fileB = createAndOpenTestVirtualFile("B.java", "class B {}") + val callback = mock() + val analysisId = UUID.randomUUID() + val state = AnalysisState(analysisId, callback, module, listOf(fileA, fileB)) + val uriA = VirtualFileUtils.toURI(fileA) + assertThat(uriA).isNotNull + + state.addRawHotspots(analysisId, emptyMap(), isIntermediate = false) + state.addRawIssues(analysisId, mapOf(uriA!! to emptyList()), isIntermediate = false) + + verify(callback).onSuccess(check { result -> + assertThat(result.analyzedFiles).containsExactlyInAnyOrder(fileA, fileB) + assertThat(result.findings.issuesPerFile.keys).containsExactly(fileA) + assertThat(result.findings.issuesPerFile[fileA]).isEmpty() + }) + } + + @Test + fun should_fall_back_to_findings_keys_when_no_submitted_files_were_passed() { + val fileA = createAndOpenTestVirtualFile("A.java", "class A {}") + val callback = mock() + val analysisId = UUID.randomUUID() + val state = AnalysisState(analysisId, callback, module) + val uriA = VirtualFileUtils.toURI(fileA) + assertThat(uriA).isNotNull + + state.addRawHotspots(analysisId, emptyMap(), isIntermediate = false) + state.addRawIssues(analysisId, mapOf(uriA!! to emptyList()), isIntermediate = false) + + verify(callback).onSuccess(check { result -> + assertThat(result.analyzedFiles).containsExactly(fileA) + }) + } +} diff --git a/src/test/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsCoordinatorTests.kt b/src/test/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsCoordinatorTests.kt new file mode 100644 index 0000000000..0f09f590f8 --- /dev/null +++ b/src/test/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsCoordinatorTests.kt @@ -0,0 +1,266 @@ +/* + * SonarLint for IntelliJ IDEA + * Copyright (C) SonarSource Sàrl + * sonarlint@sonarsource.com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 + */ +package org.sonarlint.intellij.analysis + +import com.intellij.openapi.vfs.VirtualFile +import java.time.Instant +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.reset +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.sonarlint.intellij.AbstractSonarLintLightTests +import org.sonarlint.intellij.actions.SonarLintToolWindow +import org.sonarlint.intellij.common.util.SonarLintUtils.getService +import org.sonarlint.intellij.editor.CodeAnalyzerRestarter +import org.sonarlint.intellij.editor.EditorHighlightRefresh +import org.sonarlint.intellij.finding.LiveFindings +import org.sonarlint.intellij.finding.issue.LiveIssue +import org.sonarlint.intellij.ui.filter.FilterCriteria +import org.sonarlint.intellij.ui.filter.FindingsFilter +import org.sonarlint.intellij.util.VirtualFileUtils +import org.sonarsource.sonarlint.core.rpc.protocol.client.issue.RaisedIssueDto + +class OnTheFlyFindingsCoordinatorTests : AbstractSonarLintLightTests() { + + private lateinit var restarter: CodeAnalyzerRestarter + private lateinit var toolWindow: SonarLintToolWindow + private lateinit var holder: OnTheFlyFindingsHolder + private lateinit var coordinator: OnTheFlyFindingsCoordinator + + @BeforeEach + fun prepare() { + restarter = mock() + toolWindow = mock() + whenever(toolWindow.getCurrentFileFilterCriteria()).thenReturn(FilterCriteria()) + replaceProjectService(CodeAnalyzerRestarter::class.java, restarter) + replaceProjectService(SonarLintToolWindow::class.java, toolWindow) + holder = getService(project, AnalysisSubmitter::class.java).onTheFlyFindingsHolder + coordinator = getService(project, OnTheFlyFindingsCoordinator::class.java) + holder.clearAllCurrentFileFindings() + reset(restarter) + reset(toolWindow) + } + + @Test + fun should_highlight_on_first_analysis_when_current_file_tab_was_never_created() { + val file = createAndOpenTestVirtualFile("Foo.java", "class Foo {}") + val issue = mock() + resetMocks() + + holder.updateOnAnalysisResult(analysisResult(file, issue)) + + verify(restarter).refreshFiles(check { files: Collection -> assertThat(files).contains(file) }) + assertThat(holder.getIssuesForFile(file)).containsExactly(issue) + verify(toolWindow).updateCurrentFileTab(anyOrNull()) + } + + @Test + fun should_clear_markup_when_a_previously_highlighted_file_becomes_clean() { + val file = createAndOpenTestVirtualFile("Foo.java", "class Foo {}") + holder.updateOnAnalysisResult(analysisResult(file, mock())) + resetMocks() + + holder.updateOnAnalysisResult(cleanAnalysisResult(file)) + + verify(restarter).refreshFiles(check { files: Collection -> assertThat(files).contains(file) }) + assertThat(holder.getIssuesForFile(file)).isEmpty() + } + + @Test + fun should_not_refresh_markup_for_intermediate_publication() { + val file = createAndOpenTestVirtualFile("Foo.java", "class Foo {}") + resetMocks() + + holder.updateOnAnalysisIntermediateResult( + AnalysisIntermediateResult(LiveFindings(mapOf(file to listOf(mock())), emptyMap())) + ) + + verifyNoInteractions(restarter) + verify(toolWindow).updateCurrentFileTab(anyOrNull()) + } + + @Test + fun should_clear_an_open_file_raised_with_an_empty_list_and_leave_omitted_uris_unchanged() { + val fileA = createAndOpenTestVirtualFile("A.java", "class A {}") + val fileB = createAndOpenTestVirtualFile("B.java", "class B {}") + val issueA = mock() + val issueB = mock() + holder.updateOnAnalysisResult( + AnalysisResult( + null, + LiveFindings(mapOf(fileA to listOf(issueA), fileB to listOf(issueB)), emptyMap()), + listOf(fileA, fileB), + Instant.EPOCH, + ) + ) + resetMocks() + val uriA = VirtualFileUtils.toURI(fileA) + assertThat(uriA).isNotNull + + holder.updateViewsWithNewIssues(module, mapOf(uriA!! to emptyList())) + + assertThat(holder.getIssuesForFile(fileA)).isEmpty() + assertThat(holder.getIssuesForFile(fileB)).containsExactly(issueB) + verify(restarter).refreshFiles(check { files: Collection -> + assertThat(files).contains(fileA).doesNotContain(fileB) + }) + } + + @Test + fun should_clear_markup_on_a_background_open_file_that_became_clean() { + val fileA = createAndOpenTestVirtualFile("A.java", "class A {}") + val fileB = createAndOpenTestVirtualFile("B.java", "class B {}") + val issueA = mock() + holder.updateOnAnalysisResult( + AnalysisResult( + null, + LiveFindings(mapOf(fileA to listOf(issueA), fileB to listOf(mock())), emptyMap()), + listOf(fileA, fileB), + Instant.EPOCH, + ) + ) + resetMocks() + + holder.updateOnAnalysisResult( + AnalysisResult( + null, + LiveFindings(mapOf(fileA to listOf(issueA)), emptyMap()), + listOf(fileA, fileB), + Instant.EPOCH, + ) + ) + + verify(restarter).refreshFiles(check { files: Collection -> assertThat(files).contains(fileB) }) + assertThat(holder.getIssuesForFile(fileB)).isEmpty() + assertThat(holder.getIssuesForFile(fileA)).containsExactly(issueA) + } + + @Test + fun should_keep_unfiltered_findings_for_squiggles_when_the_panel_would_filter_the_list() { + val file = createAndOpenTestVirtualFile("Foo.java", "class Foo {}") + val issue = mock() + whenever(issue.message).thenReturn("Remove this unused private field") + whenever(issue.getRuleKey()).thenReturn("java:S1068") + whenever(issue.file()).thenReturn(file) + whenever(issue.isResolved()).thenReturn(false) + resetMocks() + + holder.updateOnAnalysisResult(analysisResult(file, issue)) + + val hiddenInTheList = FindingsFilter(project).filterAllFindings( + file, + FilterCriteria(textFilter = "does-not-match-the-issue"), + ) + assertThat(hiddenInTheList.issues).isEmpty() + assertThat(holder.getIssuesForFile(file)).containsExactly(issue) + verify(toolWindow).updateCurrentFileTab(anyOrNull()) + verify(toolWindow, never()).refreshViews() + } + + @Test + fun should_not_refresh_markup_for_open_files_this_analysis_did_not_cover() { + val fileA = createAndOpenTestVirtualFile("A.java", "class A {}") + val fileB = createAndOpenTestVirtualFile("B.java", "class B {}") + val issueA = mock() + val issueB = mock() + holder.updateOnAnalysisResult( + AnalysisResult( + null, + LiveFindings(mapOf(fileA to listOf(issueA), fileB to listOf(issueB)), emptyMap()), + listOf(fileA, fileB), + Instant.EPOCH, + ) + ) + resetMocks() + + holder.updateOnAnalysisResult( + AnalysisResult( + null, + LiveFindings(mapOf(fileA to listOf(issueA)), emptyMap()), + listOf(fileA), + Instant.EPOCH, + ) + ) + + verify(restarter).refreshFiles(check { files: Collection -> + assertThat(files).contains(fileA).doesNotContain(fileB) + }) + assertThat(holder.getIssuesForFile(fileB)).containsExactly(issueB) + } + + @Test + fun should_highlight_open_files_when_the_binding_changes() { + val file = createAndOpenTestVirtualFile("Foo.java", "class Foo {}") + replaceProjectService(SonarLintToolWindow::class.java, SonarLintToolWindow(project)) + resetMocks() + + getService(project, SonarLintToolWindow::class.java).bindingChanged() + + verify(restarter).refreshFiles(check { files: Collection -> assertThat(files).contains(file) }) + } + + @Test + fun should_honor_changed_files_without_findings_scope() { + val fileA = createAndOpenTestVirtualFile("A.java", "class A {}") + val fileB = createAndOpenTestVirtualFile("B.java", "class B {}") + resetMocks() + + coordinator.applyHighlightRefresh(EditorHighlightRefresh.enabled(listOf(fileB))) + + verify(restarter).refreshFiles(check { files: Collection -> + assertThat(files).contains(fileB).doesNotContain(fileA) + }) + } + + @Test + fun should_not_refresh_markup_when_highlight_refresh_is_disabled() { + createAndOpenTestVirtualFile("Foo.java", "class Foo {}") + resetMocks() + + coordinator.applyHighlightRefresh(EditorHighlightRefresh.NONE) + + verifyNoInteractions(restarter) + } + + private fun resetMocks() { + reset(restarter) + reset(toolWindow) + } + + private fun analysisResult(file: VirtualFile, issue: LiveIssue): AnalysisResult { + return AnalysisResult( + null, + LiveFindings(mapOf(file to listOf(issue)), emptyMap()), + listOf(file), + Instant.EPOCH, + ) + } + + private fun cleanAnalysisResult(file: VirtualFile): AnalysisResult { + return AnalysisResult(null, LiveFindings.none(), listOf(file), Instant.EPOCH) + } +} diff --git a/src/test/java/org/sonarlint/intellij/editor/DirectHighlighterTests.kt b/src/test/java/org/sonarlint/intellij/editor/DirectHighlighterTests.kt index 76d2b911b2..fe512fce35 100644 --- a/src/test/java/org/sonarlint/intellij/editor/DirectHighlighterTests.kt +++ b/src/test/java/org/sonarlint/intellij/editor/DirectHighlighterTests.kt @@ -27,18 +27,27 @@ import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.vfs.VirtualFile import com.intellij.testFramework.PlatformTestUtil +import java.time.Instant import java.util.UUID import java.util.concurrent.ScheduledThreadPoolExecutor import java.util.concurrent.TimeUnit import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.mockito.Mockito import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import org.sonarlint.intellij.AbstractSonarLintLightTests +import org.sonarlint.intellij.analysis.AnalysisSubmitter import org.sonarlint.intellij.common.util.SonarLintUtils.getService +import org.sonarlint.intellij.finding.Location import org.sonarlint.intellij.finding.issue.LiveIssue +import org.sonarlint.intellij.finding.issue.vulnerabilities.LocalTaintVulnerability +import org.sonarlint.intellij.finding.issue.vulnerabilities.TaintVulnerabilitiesCache import org.sonarlint.intellij.ui.currentfile.CurrentFileDisplayedFindingsStore +import org.sonarlint.intellij.ui.filter.FilterCriteria import org.sonarlint.intellij.ui.filter.FilteredFindings +import org.sonarlint.intellij.ui.filter.FindingsFilter +import org.sonarsource.sonarlint.core.rpc.protocol.backend.tracking.TaintVulnerabilityDto import org.sonarsource.sonarlint.core.rpc.protocol.client.issue.RaisedIssueDto import org.sonarsource.sonarlint.core.rpc.protocol.common.Either import org.sonarsource.sonarlint.core.rpc.protocol.common.IssueSeverity @@ -83,8 +92,11 @@ class DirectHighlighterTests : AbstractSonarLintLightTests() { highlighter.applyHighlightsForTest(file) assertThat(sonarLintHighlights(file, issueMessage)).hasSize(1) - getService(project, CurrentFileDisplayedFindingsStore::class.java) - .setSnapshot(FilteredFindings(emptyList(), emptyList(), emptyList(), emptyList())) + getService(project, AnalysisSubmitter::class.java).onTheFlyFindingsHolder + .replaceIssuesForFile(file, emptyList()) + getService(project, CurrentFileDisplayedFindingsStore::class.java).setSnapshot( + FilteredFindings(emptyList(), emptyList(), emptyList(), emptyList()), + ) highlighter.applyHighlightsForTest(file) assertThat(sonarLintHighlights(file, issueMessage)).isEmpty() @@ -180,6 +192,47 @@ class DirectHighlighterTests : AbstractSonarLintLightTests() { } } + @Test + fun should_highlight_taints_from_the_taint_cache_without_a_findings_store() { + val content = "class Foo {}" + val file = createAndOpenTestPsiFile("Foo.java", content).virtualFile + val issueMessage = "SQL injection" + val expectedRange = textRangeOf(content, "Foo") + seedTaint(file, content, issueMessage) + + withOpenEditor(file) { + val highlighter = getService(project, DirectHighlighter::class.java) + highlighter.applyHighlightsForTest(file) + + val highlights = sonarLintHighlights(file, issueMessage) + assertThat(highlights).hasSize(1) + assertThat(highlights.single().startOffset).isEqualTo(expectedRange.first) + assertThat(highlights.single().endOffset).isEqualTo(expectedRange.second) + } + } + + + @Test + fun should_hide_editor_squiggle_when_a_list_filter_hides_the_issue() { + val content = "class Foo {}" + val file = createAndOpenTestPsiFile("Foo.java", content).virtualFile + val issueMessage = "Remove this unused private field" + + seedDisplayedIssue(file, content, issueMessage) + + val filterCriteria = FilterCriteria(textFilter = "does-not-match-the-issue") + val hiddenInList = FindingsFilter(project).filterAllFindings(file, filterCriteria) + assertThat(hiddenInList.issues).isEmpty() + + withOpenEditor(file) { + getService(project, CurrentFileDisplayedFindingsStore::class.java).setSnapshot(hiddenInList) + val highlighter = getService(project, DirectHighlighter::class.java) + highlighter.applyHighlightsForTest(file) + + assertThat(sonarLintHighlights(file, issueMessage)).isEmpty() + } + } + private fun seedDisplayedIssue(file: VirtualFile, content: String, message: String) { val document = FileDocumentManager.getInstance().getDocument(file)!! val (startOffset, endOffset) = textRangeOf(content, "Foo") @@ -193,8 +246,33 @@ class DirectHighlighterTests : AbstractSonarLintLightTests() { whenever(issueDto.isOnNewCode).thenReturn(false) val issue = LiveIssue(module, issueDto, file, rangeMarker, null, emptyList()) - getService(project, CurrentFileDisplayedFindingsStore::class.java) - .setSnapshot(FilteredFindings(listOf(issue), emptyList(), emptyList(), emptyList())) + getService(project, AnalysisSubmitter::class.java).onTheFlyFindingsHolder.replaceIssuesForFile(file, listOf(issue)) + getService(project, CurrentFileDisplayedFindingsStore::class.java).setSnapshot( + FilteredFindings(listOf(issue), emptyList(), emptyList(), emptyList()), + ) + } + + private fun seedTaint(file: VirtualFile, content: String, message: String) { + val document = FileDocumentManager.getInstance().getDocument(file)!! + val (startOffset, endOffset) = textRangeOf(content, "Foo") + val rangeMarker = document.createRangeMarker(startOffset, endOffset) + val dto = Mockito.mock(TaintVulnerabilityDto::class.java, Mockito.RETURNS_DEEP_STUBS) + whenever(dto.message).thenReturn(message) + whenever(dto.introductionDate).thenReturn(Instant.EPOCH) + whenever(dto.isAiCodeFixable).thenReturn(false) + whenever(dto.isOnNewCode).thenReturn(false) + whenever(dto.severityMode).thenReturn(Either.forLeft(StandardModeDetails(IssueSeverity.MAJOR, RuleType.VULNERABILITY))) + whenever(dto.ruleKey).thenReturn("javasecurity:S3649") + whenever(dto.id).thenReturn(UUID.randomUUID()) + whenever(dto.sonarServerKey).thenReturn("taint-key") + val taint = LocalTaintVulnerability( + module, + Location(file, rangeMarker, message, null, null), + emptyList(), + dto, + false, + ) + getService(project, TaintVulnerabilitiesCache::class.java).taintVulnerabilities = listOf(taint) } private fun textRangeOf(content: String, token: String): Pair { diff --git a/src/test/java/org/sonarlint/intellij/ui/currentfile/CurrentFileDisplayedFindingsStoreTests.java b/src/test/java/org/sonarlint/intellij/ui/currentfile/CurrentFileDisplayedFindingsStoreTests.java deleted file mode 100644 index 333b45275e..0000000000 --- a/src/test/java/org/sonarlint/intellij/ui/currentfile/CurrentFileDisplayedFindingsStoreTests.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * SonarLint for IntelliJ IDEA - * Copyright (C) SonarSource Sàrl - * sonarlint@sonarsource.com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this program; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02 - */ -package org.sonarlint.intellij.ui.currentfile; - -import com.intellij.openapi.project.Project; -import com.intellij.openapi.vfs.VirtualFile; -import java.util.Collections; -import java.util.List; -import org.junit.jupiter.api.Test; -import org.sonarlint.intellij.finding.issue.LiveIssue; -import org.sonarlint.intellij.ui.filter.FilteredFindings; -import org.sonarsource.sonarlint.core.rpc.protocol.client.issue.RaisedIssueDto; -import org.sonarsource.sonarlint.core.rpc.protocol.common.Either; -import org.sonarsource.sonarlint.core.rpc.protocol.common.IssueSeverity; -import org.sonarsource.sonarlint.core.rpc.protocol.common.RuleType; -import org.sonarsource.sonarlint.core.rpc.protocol.common.StandardModeDetails; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class CurrentFileDisplayedFindingsStoreTests { - - @Test - void should_return_empty_findings_before_any_snapshot() { - var store = newStore(); - var file = mock(VirtualFile.class); - - var forFile = store.getFindingsForFile(file); - - assertThat(forFile.getIssues()).isEmpty(); - assertThat(forFile.getHotspots()).isEmpty(); - assertThat(forFile.getTaints()).isEmpty(); - assertThat(forFile.getDependencyRisks()).isEmpty(); - } - - @Test - void should_filter_snapshot_by_file_like_findings_tab() { - var store = newStore(); - var fileA = mock(VirtualFile.class); - var fileB = mock(VirtualFile.class); - var issueOnA = liveIssueOnFile(fileA, "on A"); - var issueOnB = liveIssueOnFile(fileB, "on B"); - store.setSnapshot(new FilteredFindings(List.of(issueOnA, issueOnB), List.of(), List.of(), List.of())); - - assertThat(store.getFindingsForFile(fileA).getIssues()).containsExactly(issueOnA); - assertThat(store.getFindingsForFile(fileB).getIssues()).containsExactly(issueOnB); - } - - @Test - void should_replace_snapshot_on_update() { - var store = newStore(); - var file = mock(VirtualFile.class); - var first = liveIssueOnFile(file, "first"); - var second = liveIssueOnFile(file, "second"); - store.setSnapshot(new FilteredFindings(List.of(first), List.of(), List.of(), List.of())); - store.setSnapshot(new FilteredFindings(List.of(second), List.of(), List.of(), List.of())); - - assertThat(store.getFindingsForFile(file).getIssues()).containsExactly(second); - } - - private static CurrentFileDisplayedFindingsStore newStore() { - return new CurrentFileDisplayedFindingsStore(mock(Project.class)); - } - - private static LiveIssue liveIssueOnFile(VirtualFile file, String message) { - var issue = mock(RaisedIssueDto.class); - when(issue.getPrimaryMessage()).thenReturn(message); - when(issue.getSeverityMode()).thenReturn(Either.forLeft(new StandardModeDetails(IssueSeverity.MAJOR, RuleType.BUG))); - return new LiveIssue(null, issue, file, Collections.emptyList()); - } - -}