Skip to content

SLI-2850 Decouple editor highlighting from the Current File tool window - #1876

Draft
nquinquenel wants to merge 3 commits into
masterfrom
jira/SLI-2850-decouple-editor-highlighting
Draft

SLI-2850 Decouple editor highlighting from the Current File tool window#1876
nquinquenel wants to merge 3 commits into
masterfrom
jira/SLI-2850-decouple-editor-highlighting

Conversation

@nquinquenel

@nquinquenel nquinquenel commented Sep 1, 2026

Copy link
Copy Markdown
Member

Summary

  • SLI-2850: editor squiggles no longer depend on the Current File tool window existing or writing a snapshot the highlighter reads.
  • New project service OnTheFlyFindingsCoordinator refreshes DirectHighlighter from unfiltered on-the-fly findings (plus taints). Files that just became clean are cleared without close/reopen.
  • CurrentFilePanel is UI-only (list, filters, gutter). CurrentFileDisplayedFindingsStore is deleted. SonarLintToolWindow.refreshViews highlights via the coordinator first, then updates the tab if it is present.

Implementation contract

  • Coordinator publishes highlights; OnTheFlyFindingsHolder keeps the per-open-file maps.
  • Highlighter reads coordinator issues/hotspots + TaintVulnerabilitiesCache, not a filtered UI snapshot. No FindingsScope on the highlight file set.
  • Sequential: write/replace findings (including empty) → markup → tool window with EditorHighlightRefresh.NONE if the panel exists.
  • raiseIssues / analysis-result replace per file including empties; analyzedFiles ∩ open files with no findings are stored empty.
  • List filters stay panel-owned; squiggles do not follow search/severity/scope. Gutter icons stay panel-driven.

Review

  • Correctness: approve (no blocking). Notes: SSH/dev-container openFiles fallback is unchanged; one test does not actually apply a panel filter.
  • Security: approve (no blocking).
  • Ticket-fit: approve (no blocking). Coordinator is a publisher; holder still owns the maps (as contracted).

Test plan

  • ./gradlew :test --tests org.sonarlint.intellij.analysis.OnTheFlyFindingsCoordinatorTests --tests org.sonarlint.intellij.editor.DirectHighlighterTests --tests org.sonarlint.intellij.actions.ClearCurrentFileIssuesActionTests --tests org.sonarlint.intellij.editor.CodeAnalyzerRestarterTests
  • Reproduce community unused-field case: flag unused, add a usage, confirm squiggle clears without close/reopen
  • Confirm highlighting still works if the Current File tool window was never opened
  • Confirm Current File tab still updates when it is open
  • Confirm Findings list filters no longer hide editor squiggles (accepted product change)

Refresh editor squiggles from on-the-fly findings even when the Current File tab was never created, and clear markup for files that became clean.
@hashicorp-vault-sonar-prod

hashicorp-vault-sonar-prod Bot commented Sep 1, 2026

Copy link
Copy Markdown

SLI-2850

Comment thread src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsHolder.kt Outdated
Comment thread src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsHolder.kt Outdated
@nquinquenel
nquinquenel force-pushed the jira/SLI-2850-decouple-editor-highlighting branch from 929f5c2 to 22687e0 Compare September 2, 2026 08:09
@nquinquenel
nquinquenel force-pushed the jira/SLI-2850-decouple-editor-highlighting branch from 22687e0 to ccf4422 Compare September 2, 2026 08:29
@datadog-sonarsource

This comment has been minimized.

Comment on lines +37 to +42
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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 Bug: Single-file filtered snapshot wipes squiggles in background open files

CurrentFileDisplayedFindingsStore holds one global FilteredFindings snapshot, and both writers populate it for a single file only: the refresher calls filterAllFindings(getSelectedFile(project), criteria) and the panel calls it for currentFile, with the default FindingsScope.CURRENT_FILE loading findings for that one file. DirectHighlighter.collectHighlightPlans now reads getFindingsForFile(file) from that snapshot for every file the coordinator refreshes. Trigger: files A (selected) and B (background) are open, an analysis raises issues for B → holder publishes enabled(changedFiles={B}) → the refresher rebuilds the snapshot for A → refreshFiles([B])getFindingsForFile(B) returns empty → all SonarQube markup on B is erased, and selecting B afterwards goes through panel.update(B, EditorHighlightRefresh.NONE) which sets the snapshot but never re-renders markup. Key the store per file (write a per-file map, or have the highlighter compute findings for the file it is rendering) instead of a single selected-file snapshot.

Fix 1: Store one snapshot per file so refreshing markup for a non-selected file does not read an unrelated file's snapshot
// CurrentFileDisplayedFindingsStore.kt
private val snapshotsPerFile = ConcurrentHashMap<VirtualFile, FilteredFindings>()

fun setSnapshot(file: VirtualFile?, findings: FilteredFindings) {
    // index by the file each finding belongs to, so background files keep their own snapshot
    findings.issues.map { it.file() }.plus(findings.hotspots.map { it.file() })
        .plus(findings.taints.mapNotNull { it.file() }).plus(listOfNotNull(file)).distinct()
        .forEach { f -> snapshotsPerFile[f] = findings.getFindingsForFile(f) }
}

fun getFindingsForFile(file: VirtualFile): FilteredFindings = snapshotsPerFile[file] ?: EMPTY
  • Apply fix
Fix 2: Drop the shared snapshot and filter per rendered file at highlight time
// DirectHighlighter.collectHighlightPlans - compute for the file being rendered
val criteria = getService(project, SonarLintToolWindow::class.java).getCurrentFileFilterCriteria()
    ?: FilterCriteria()
val findings = FindingsFilter(project).filterAllFindings(file, criteria).getFindingsForFile(file)
  • Apply fix

Check a box to apply a fix or reply for a change | Was this helpful? React with 👍 / 👎

Comment on lines +195 to +209
@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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Bug: Taint highlight test now fails: taints only come from the snapshot

should_highlight_taints_from_the_taint_cache_without_a_findings_store seeds a taint only into TaintVulnerabilitiesCache (seedTaint) and asserts one highlight, but this commit removed the TaintVulnerabilitiesCache read from collectHighlightPlans — taints now come exclusively from CurrentFileDisplayedFindingsStore, whose snapshot is never set in that test, so findings.taints is empty and the hasSize(1) assertion fails. Either seed the store in seedTaint (and rename the test, since its premise "without a findings store" no longer holds) or keep the taint cache as a highlight source.

Seed the displayed-findings store alongside the taint cache and rename the test to drop the "without a findings store" claim:

private fun seedTaint(file: VirtualFile, content: String, message: String) {
    // ... existing taint construction ...
    getService(project, TaintVulnerabilitiesCache::class.java).taintVulnerabilities = listOf(taint)
    getService(project, CurrentFileDisplayedFindingsStore::class.java).setSnapshot(
        FilteredFindings(emptyList(), emptyList(), listOf(taint), emptyList()),
    )
}
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

Comment on lines 448 to 462
@@ -467,7 +461,7 @@ class CurrentFilePanel(project: Project) : CurrentFileFindingsPanel(project) {
val files = when {
highlightRefresh.allOpenFiles -> FileEditorManager.getInstance(project).openFiles.toList()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Bug: Panel re-gates highlight files by FindingsScope, contradicting the contract

resolveEditorHighlightFiles checks filterCriteria.findingsScope == FindingsScope.CURRENT_FILE before highlightRefresh.changedFiles, so with the default CURRENT_FILE scope a refreshView(EditorHighlightRefresh.enabled(changedFiles)) collapses the file set to listOfNotNull(file) and every other open file keeps stale markup. This directly contradicts EditorHighlightRefresh's own KDoc ("[changedFiles] ... honored whenever present", "the concrete set of files is resolved by OnTheFlyFindingsCoordinator, not the Current File panel") and the PR's stated contract of no FindingsScope on the highlight file set. Let the coordinator resolve the files (applyHighlightRefresh(highlightRefresh)) and drop the scope branch.

Delegate file resolution to the coordinator instead of re-gating it by the panel's findings scope:

private fun refreshEditorHighlights(highlightRefresh: EditorHighlightRefresh) {
    getService(project, OnTheFlyFindingsCoordinator::class.java).applyHighlightRefresh(highlightRefresh)
}
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

Comment on lines +37 to +42
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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Bug: Filter criteria read off the EDT from the analysis publish thread

OnTheFlyFindingsHolder.updateViewsWithNewIssues / updateOnAnalysisResult run on the backend RPC/analysis thread and now reach coordinator.applyHighlightRefreshrefreshDisplayedFindingsSonarLintToolWindow.getCurrentFileFilterCriteria, which touches ToolWindowManager/ContentManager.findContent and then reads live Swing state (filtersPanel.filterText, quickFixCheckBox.isSelected, findingsScope) from that background thread — a platform threading violation and a race with concurrent EDT filter edits. Read the criteria on the EDT (or cache the last criteria in a thread-safe holder the panel updates on the EDT) before computing the snapshot.

Fetch the panel's filter criteria on the EDT before filtering off-thread:

fun refreshDisplayedFindings(file: VirtualFile?) {
    val criteria = computeOnEdt(project) {
        getService(project, SonarLintToolWindow::class.java).getCurrentFileFilterCriteria() ?: FilterCriteria()
    }
    val filteredFindings = FindingsFilter(project).filterAllFindings(file, criteria)
    getService(project, CurrentFileDisplayedFindingsStore::class.java).setSnapshot(filteredFindings)
}
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

Comment on lines 240 to 245
/**
* 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<HighlightPlan> {
val findings = getService(project, CurrentFileDisplayedFindingsStore::class.java).getFindingsForFile(file)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Quality: Diff contradicts the PR's stated contract on filter decoupling

The PR description's implementation contract states the highlighter reads unfiltered coordinator issues/hotspots plus TaintVulnerabilitiesCache, that CurrentFileDisplayedFindingsStore is deleted, and that "squiggles do not follow search/severity/scope"; this commit reinstates the store as the highlighter's only source and renames the guarding test to should_hide_editor_squiggle_when_a_list_filter_hides_the_issue, so a text filter in the Findings list once again hides editor squiggles. Either update the description/ticket scope to record this reversal as intentional, or restore the unfiltered read path so the decoupling the PR advertises actually holds.

Align the stated contract with the code (or revert the code to the stated contract):

// Update the PR description's "Implementation contract" to state:
// - Highlighter reads the filtered snapshot published by CurrentFileDisplayedFindingsRefresher
// - Editor squiggles DO follow Current File tab filters (search/severity/scope)
// ... or revert DirectHighlighter.collectHighlightPlans to the holder + TaintVulnerabilitiesCache read.
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

Comment on lines +62 to +67
fun applyHighlightRefreshAndRefreshPanels(highlightRefresh: EditorHighlightRefresh) {
applyHighlightRefresh(highlightRefresh)
if (!project.isDisposed) {
getService(project, SonarLintToolWindow::class.java).refreshViews()
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Performance: Highlight refresh and filtering run twice per resolve/CAYC/binding event

applyHighlightRefreshAndRefreshPanels calls applyHighlightRefresh (snapshot rebuild + refreshFiles) and then refreshViews(), which now calls panel.refreshView(EditorHighlightRefresh.enabled()) whose finally block calls applyHighlightRefresh again — so every mark-as-resolved, reopen, CAYC toggle and binding change re-filters all findings and re-queues markup twice, the second time on the EDT. Pass EditorHighlightRefresh.NONE from refreshViews() (the coordinator already handled markup) and keep the panel's highlight trigger for callers that only rebuild the panel.

Let the coordinator own the markup pass so panel rebuilds do not duplicate it:

public void refreshViews() {
  this.<CurrentFilePanel>updateCurrentFileTab(panel -> panel.refreshView(EditorHighlightRefresh.NONE));
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

@gitar-bot gitar-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ This PR is blocked due to unresolved code review findings.

Comment gitar unblock to override this block and allow merging.

Configure merge blocking · Maintainers can dismiss this review.

@gitar-bot

gitar-bot Bot commented Sep 2, 2026

Copy link
Copy Markdown
CI failed: UI integration test failures across IDE templates due to timeouts waiting for expected issue count text caused by editor highlighting and tool window decoupling changes.

Overview

Two UI integration test jobs failed across different IDE environments (IdeaUltimate2024 and PyCharmProfessional2024) due to remote-robot timeout exceptions waiting for the text 'Found 1 issue' in the UI fixture, directly relating to the PR's changes in decoupling editor highlighting from the Current File tool window.

Failures

IdeaUltimateTests UI Timeout (confidence: high)

  • Type: test
  • Affected jobs: 100207647868
  • Related to change: yes
  • Root cause: The integration tests timed out (WaitForConditionTimeoutException) waiting to find 'Found 1 issue' in the UI, stemming from recent changes to editor highlighting and the Current File tool window.
  • Suggested fix: Verify that recent changes to editor highlighting and findings display correctly update the UI text and propagate findings so remote-robot can locate the expected text within the timeout period.

PyCharmProfessionalTests UI Timeout (confidence: high)

  • Type: test
  • Affected jobs: 100207647875
  • Related to change: yes
  • Root cause: An integration test timed out after one minute while trying to find 'Found 1 issue' in the remote robot fixture UI, likely due to recent decoupling changes in editor highlighting and the Current File tool window refresher.
  • Suggested fix: Investigate recent updates in OnTheFlyFindingsCoordinator, DirectHighlighter, and the Current File tool window refresher to ensure issues are correctly and promptly displayed in the UI.

Summary

  • Change-related failures: 2 test failures caused by UI timing/display issues related to the decoupling of editor highlighting and the tool window.
  • Infrastructure/flaky failures: None.
  • Recommended action: Review the changes made to editor highlighting and findings stores to ensure the UI properly updates and displays issues within the expected time during integration tests.
Code Review 🚫 Blocked 7 resolved / 13 findings

Decouples editor highlighting from the Current File tool window, but several critical issues block merge: a single-file filtered snapshot wipes squiggles in background open files (reuse the snapshot per-file instead); the panel re-gates highlight files by FindingsScope, contradicting the stated contract (drop the scope branch); filter criteria are read off the EDT from the analysis thread, a platform threading violation (read on EDT or cache in a thread-safe holder); the diff reinstates the filtered store as the highlighter's only source, contradicting the PR description's stated decoupling contract (clarify intent or restore unfiltered reads); a taint highlight test now fails because taints only come from the snapshot (seed the store or keep the taint cache as a source); and highlight refresh and filtering run twice per event (pass EditorHighlightRefresh.NONE from refreshViews() to avoid duplicate work).

🚨 Bug: Single-file filtered snapshot wipes squiggles in background open files

📄 src/main/java/org/sonarlint/intellij/ui/currentfile/CurrentFileDisplayedFindingsRefresher.kt:37-42 📄 src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsCoordinator.kt:49-56 📄 src/main/java/org/sonarlint/intellij/editor/DirectHighlighter.kt:244-258 📄 src/main/java/org/sonarlint/intellij/ui/currentfile/CurrentFilePanel.kt:382

CurrentFileDisplayedFindingsStore holds one global FilteredFindings snapshot, and both writers populate it for a single file only: the refresher calls filterAllFindings(getSelectedFile(project), criteria) and the panel calls it for currentFile, with the default FindingsScope.CURRENT_FILE loading findings for that one file. DirectHighlighter.collectHighlightPlans now reads getFindingsForFile(file) from that snapshot for every file the coordinator refreshes. Trigger: files A (selected) and B (background) are open, an analysis raises issues for B → holder publishes enabled(changedFiles={B}) → the refresher rebuilds the snapshot for A → refreshFiles([B])getFindingsForFile(B) returns empty → all SonarQube markup on B is erased, and selecting B afterwards goes through panel.update(B, EditorHighlightRefresh.NONE) which sets the snapshot but never re-renders markup. Key the store per file (write a per-file map, or have the highlighter compute findings for the file it is rendering) instead of a single selected-file snapshot.

Store one snapshot per file so refreshing markup for a non-selected file does not read an unrelated file's snapshot
// CurrentFileDisplayedFindingsStore.kt
private val snapshotsPerFile = ConcurrentHashMap<VirtualFile, FilteredFindings>()

fun setSnapshot(file: VirtualFile?, findings: FilteredFindings) {
    // index by the file each finding belongs to, so background files keep their own snapshot
    findings.issues.map { it.file() }.plus(findings.hotspots.map { it.file() })
        .plus(findings.taints.mapNotNull { it.file() }).plus(listOfNotNull(file)).distinct()
        .forEach { f -> snapshotsPerFile[f] = findings.getFindingsForFile(f) }
}

fun getFindingsForFile(file: VirtualFile): FilteredFindings = snapshotsPerFile[file] ?: EMPTY
Drop the shared snapshot and filter per rendered file at highlight time
// DirectHighlighter.collectHighlightPlans - compute for the file being rendered
val criteria = getService(project, SonarLintToolWindow::class.java).getCurrentFileFilterCriteria()
    ?: FilterCriteria()
val findings = FindingsFilter(project).filterAllFindings(file, criteria).getFindingsForFile(file)
⚠️ Bug: Taint highlight test now fails: taints only come from the snapshot

📄 src/test/java/org/sonarlint/intellij/editor/DirectHighlighterTests.kt:195-209 📄 src/main/java/org/sonarlint/intellij/editor/DirectHighlighter.kt:244-258

should_highlight_taints_from_the_taint_cache_without_a_findings_store seeds a taint only into TaintVulnerabilitiesCache (seedTaint) and asserts one highlight, but this commit removed the TaintVulnerabilitiesCache read from collectHighlightPlans — taints now come exclusively from CurrentFileDisplayedFindingsStore, whose snapshot is never set in that test, so findings.taints is empty and the hasSize(1) assertion fails. Either seed the store in seedTaint (and rename the test, since its premise "without a findings store" no longer holds) or keep the taint cache as a highlight source.

Seed the displayed-findings store alongside the taint cache and rename the test to drop the "without a findings store" claim
private fun seedTaint(file: VirtualFile, content: String, message: String) {
    // ... existing taint construction ...
    getService(project, TaintVulnerabilitiesCache::class.java).taintVulnerabilities = listOf(taint)
    getService(project, CurrentFileDisplayedFindingsStore::class.java).setSnapshot(
        FilteredFindings(emptyList(), emptyList(), listOf(taint), emptyList()),
    )
}
⚠️ Bug: Panel re-gates highlight files by FindingsScope, contradicting the contract

📄 src/main/java/org/sonarlint/intellij/ui/currentfile/CurrentFilePanel.kt:448-462 📄 src/main/java/org/sonarlint/intellij/editor/EditorHighlightRefresh.kt:28-38

resolveEditorHighlightFiles checks filterCriteria.findingsScope == FindingsScope.CURRENT_FILE before highlightRefresh.changedFiles, so with the default CURRENT_FILE scope a refreshView(EditorHighlightRefresh.enabled(changedFiles)) collapses the file set to listOfNotNull(file) and every other open file keeps stale markup. This directly contradicts EditorHighlightRefresh's own KDoc ("[changedFiles] ... honored whenever present", "the concrete set of files is resolved by OnTheFlyFindingsCoordinator, not the Current File panel") and the PR's stated contract of no FindingsScope on the highlight file set. Let the coordinator resolve the files (applyHighlightRefresh(highlightRefresh)) and drop the scope branch.

Delegate file resolution to the coordinator instead of re-gating it by the panel's findings scope
private fun refreshEditorHighlights(highlightRefresh: EditorHighlightRefresh) {
    getService(project, OnTheFlyFindingsCoordinator::class.java).applyHighlightRefresh(highlightRefresh)
}
⚠️ Bug: Filter criteria read off the EDT from the analysis publish thread

📄 src/main/java/org/sonarlint/intellij/ui/currentfile/CurrentFileDisplayedFindingsRefresher.kt:37-42 📄 src/main/java/org/sonarlint/intellij/actions/SonarLintToolWindow.java:169-178 📄 src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsHolder.kt:80-94 📄 src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsHolder.kt:218-232

OnTheFlyFindingsHolder.updateViewsWithNewIssues / updateOnAnalysisResult run on the backend RPC/analysis thread and now reach coordinator.applyHighlightRefreshrefreshDisplayedFindingsSonarLintToolWindow.getCurrentFileFilterCriteria, which touches ToolWindowManager/ContentManager.findContent and then reads live Swing state (filtersPanel.filterText, quickFixCheckBox.isSelected, findingsScope) from that background thread — a platform threading violation and a race with concurrent EDT filter edits. Read the criteria on the EDT (or cache the last criteria in a thread-safe holder the panel updates on the EDT) before computing the snapshot.

Fetch the panel's filter criteria on the EDT before filtering off-thread
fun refreshDisplayedFindings(file: VirtualFile?) {
    val criteria = computeOnEdt(project) {
        getService(project, SonarLintToolWindow::class.java).getCurrentFileFilterCriteria() ?: FilterCriteria()
    }
    val filteredFindings = FindingsFilter(project).filterAllFindings(file, criteria)
    getService(project, CurrentFileDisplayedFindingsStore::class.java).setSnapshot(filteredFindings)
}
⚠️ Quality: Diff contradicts the PR's stated contract on filter decoupling

📄 src/main/java/org/sonarlint/intellij/editor/DirectHighlighter.kt:240-245 📄 src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsCoordinator.kt:33-41 📄 src/test/java/org/sonarlint/intellij/editor/DirectHighlighterTests.kt:215-229

The PR description's implementation contract states the highlighter reads unfiltered coordinator issues/hotspots plus TaintVulnerabilitiesCache, that CurrentFileDisplayedFindingsStore is deleted, and that "squiggles do not follow search/severity/scope"; this commit reinstates the store as the highlighter's only source and renames the guarding test to should_hide_editor_squiggle_when_a_list_filter_hides_the_issue, so a text filter in the Findings list once again hides editor squiggles. Either update the description/ticket scope to record this reversal as intentional, or restore the unfiltered read path so the decoupling the PR advertises actually holds.

Align the stated contract with the code (or revert the code to the stated contract)
// Update the PR description's "Implementation contract" to state:
// - Highlighter reads the filtered snapshot published by CurrentFileDisplayedFindingsRefresher
// - Editor squiggles DO follow Current File tab filters (search/severity/scope)
// ... or revert DirectHighlighter.collectHighlightPlans to the holder + TaintVulnerabilitiesCache read.
💡 Performance: Highlight refresh and filtering run twice per resolve/CAYC/binding event

📄 src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsCoordinator.kt:62-67 📄 src/main/java/org/sonarlint/intellij/actions/SonarLintToolWindow.java:125-127 📄 src/main/java/org/sonarlint/intellij/ui/currentfile/CurrentFilePanel.kt:441-453 📄 src/main/java/org/sonarlint/intellij/ui/currentfile/CurrentFilePanel.kt:513-522

applyHighlightRefreshAndRefreshPanels calls applyHighlightRefresh (snapshot rebuild + refreshFiles) and then refreshViews(), which now calls panel.refreshView(EditorHighlightRefresh.enabled()) whose finally block calls applyHighlightRefresh again — so every mark-as-resolved, reopen, CAYC toggle and binding change re-filters all findings and re-queues markup twice, the second time on the EDT. Pass EditorHighlightRefresh.NONE from refreshViews() (the coordinator already handled markup) and keep the panel's highlight trigger for callers that only rebuild the panel.

Let the coordinator own the markup pass so panel rebuilds do not duplicate it
public void refreshViews() {
  this.<CurrentFilePanel>updateCurrentFileTab(panel -> panel.refreshView(EditorHighlightRefresh.NONE));
✅ 7 resolved
Bug: analyzedFiles-based clearing is unreachable in production

📄 src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsHolder.kt:78-92 📄 src/test/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsCoordinatorTests.kt:104-118
The new analyzedFiles empty-replacement block cannot do what the PR contract claims ("analyzedFiles ∩ open files with no findings are stored empty"): AnalysisState builds AnalysisResult.analyzedFiles as liveIssues.keys (AnalysisState.kt:99) or liveHotspots.keys (AnalysisState.kt:71) — i.e. exactly the key sets of the LiveFindings maps it passes alongside. So on an issues-terminated analysis every analyzedOpenFile is already a key of filteredFindings.issuesPerFile, and if (file !in filteredFindings.issuesPerFile) never fires; on a hotspot-terminated analysis (bound projects) the file set comes from hotspot keys, so the issue half runs against hotspot keys while the hotspot half is dead. Clearing a file that became clean still only works when the backend re-raises that file with an empty list, exactly as before this PR. The two coordinator tests that cover it (should_clear_markup_on_a_background_open_file_that_became_clean, should_clear_an_open_file_raised_with_an_empty_list...) hand-build AnalysisResult(analyzedFiles = listOf(fileA, fileB)), a shape AnalysisState never produces, so the gap is invisible. Either make AnalysisResult carry the files actually submitted for analysis (and derive the empty replacements from that), or drop the block and the contract line that promises it.

Performance: Every analysis now re-renders markup for all open files with findings

📄 src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsHolder.kt:72-86 📄 src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsCoordinator.kt:59-65 📄 src/main/java/org/sonarlint/intellij/editor/DirectHighlighter.kt:94-108 📄 src/main/java/org/sonarlint/intellij/editor/DirectHighlighter.kt:198-200
changedFiles still unions previouslyHighlightedOpenFiles (all open files present in either map), but the scope narrowing that used to collapse that set to the selected file in the default FindingsScope.CURRENT_FILE view (CurrentFilePanel.resolveEditorHighlightFiles, removed here) is gone, and OnTheFlyFindingsCoordinator.resolveFiles honors changedFiles verbatim. Typing in one file therefore schedules a full highlight recompute plus an EDT setHighlightersToEditor write for every other open editor that has findings, even though their findings were untouched. Restrict the previously-highlighted set to files this analysis actually covered.

Quality: Test asserts on DirectHighlighter source text via a relative path

📄 src/test/java/org/sonarlint/intellij/editor/DirectHighlighterTests.kt:209-214
should_not_depend_on_current_file_displayed_findings_store reads src/main/java/.../DirectHighlighter.kt with a CWD-relative Path.of and asserts the file text does not contain CurrentFileDisplayedFindingsStore or ui.currentfile. It fails as soon as the test working directory differs from the module root, and it also fails on any unrelated future import from ui.currentfile while proving nothing about runtime behavior. The decoupling is already covered behaviorally by should_highlight_taints_from_the_taint_cache_without_a_findings_store; delete this source-scanning test.

Quality: Filter-independence test never applies a panel filter

📄 src/test/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsCoordinatorTests.kt:159-170
should_keep_unfiltered_findings_for_squiggles_when_the_panel_would_filter_the_list only runs one analysis and asserts the coordinator returns the issue — no severity/status/search/scope filter is ever set, so the PR's headline behavior change (list filters no longer suppress editor squiggles) has no coverage. Set a panel filter that would exclude the issue (e.g. via CurrentFilePanel.allowResolvedFindings / a StatusFilter/severity criteria) before asserting coordinator.getIssuesForFile(file) still contains it, or rename the test to what it actually checks.

Quality: Coordinator doc and PR contract contradict the new code

📄 src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsCoordinator.kt:31-36 📄 src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsCoordinator.kt:51-60 📄 src/main/java/org/sonarlint/intellij/editor/DirectHighlighter.kt:241-250 📄 src/main/java/org/sonarlint/intellij/actions/SonarLintToolWindow.java:120-134
This commit inverts the direction described by the coordinator's own KDoc and by the PR's "Implementation contract". DirectHighlighter.collectHighlightPlans now reads getService(project, AnalysisSubmitter::class.java).onTheFlyFindingsHolder directly and the coordinator's getIssuesForFile/getHotspotsForFile were deleted, yet the KDoc still opens with "Publishes on-the-fly findings into editor markup" and the PR states "Highlighter reads coordinator issues/hotspots + TaintVulnerabilitiesCache". Likewise SonarLintToolWindow.refreshViews() no longer applies any highlight refresh (it did before, via refreshViews(EditorHighlightRefresh.enabled())), which invalidates the contract line "SonarLintToolWindow.refreshViews highlights via the coordinator first, then updates the tab if it is present" and "tool window with EditorHighlightRefresh.NONE if the panel exists". A maintainer reading either the class doc or the PR description will assume refreshViews() still refreshes markup and will use it as such. Update the KDoc to say the service only resolves an EditorHighlightRefresh into a file set and triggers CodeAnalyzerRestarter, and correct the PR description.

...and 2 more resolved from earlier reviews

🤖 Prompt for agents
Code Review: Decouples editor highlighting from the Current File tool window, but several critical issues block merge: a single-file filtered snapshot wipes squiggles in background open files (reuse the snapshot per-file instead); the panel re-gates highlight files by FindingsScope, contradicting the stated contract (drop the scope branch); filter criteria are read off the EDT from the analysis thread, a platform threading violation (read on EDT or cache in a thread-safe holder); the diff reinstates the filtered store as the highlighter's only source, contradicting the PR description's stated decoupling contract (clarify intent or restore unfiltered reads); a taint highlight test now fails because taints only come from the snapshot (seed the store or keep the taint cache as a source); and highlight refresh and filtering run twice per event (pass EditorHighlightRefresh.NONE from refreshViews() to avoid duplicate work).

1. 🚨 Bug: Single-file filtered snapshot wipes squiggles in background open files
   Files: src/main/java/org/sonarlint/intellij/ui/currentfile/CurrentFileDisplayedFindingsRefresher.kt:37-42, src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsCoordinator.kt:49-56, src/main/java/org/sonarlint/intellij/editor/DirectHighlighter.kt:244-258, src/main/java/org/sonarlint/intellij/ui/currentfile/CurrentFilePanel.kt:382

   `CurrentFileDisplayedFindingsStore` holds one global `FilteredFindings` snapshot, and both writers populate it for a single file only: the refresher calls `filterAllFindings(getSelectedFile(project), criteria)` and the panel calls it for `currentFile`, with the default `FindingsScope.CURRENT_FILE` loading findings for that one file. `DirectHighlighter.collectHighlightPlans` now reads `getFindingsForFile(file)` from that snapshot for *every* file the coordinator refreshes. Trigger: files A (selected) and B (background) are open, an analysis raises issues for B → holder publishes `enabled(changedFiles={B})` → the refresher rebuilds the snapshot for A → `refreshFiles([B])` → `getFindingsForFile(B)` returns empty → all SonarQube markup on B is erased, and selecting B afterwards goes through `panel.update(B, EditorHighlightRefresh.NONE)` which sets the snapshot but never re-renders markup. Key the store per file (write a per-file map, or have the highlighter compute findings for the file it is rendering) instead of a single selected-file snapshot.

   Fix (Store one snapshot per file so refreshing markup for a non-selected file does not read an unrelated file's snapshot):
   // CurrentFileDisplayedFindingsStore.kt
   private val snapshotsPerFile = ConcurrentHashMap<VirtualFile, FilteredFindings>()
   
   fun setSnapshot(file: VirtualFile?, findings: FilteredFindings) {
       // index by the file each finding belongs to, so background files keep their own snapshot
       findings.issues.map { it.file() }.plus(findings.hotspots.map { it.file() })
           .plus(findings.taints.mapNotNull { it.file() }).plus(listOfNotNull(file)).distinct()
           .forEach { f -> snapshotsPerFile[f] = findings.getFindingsForFile(f) }
   }
   
   fun getFindingsForFile(file: VirtualFile): FilteredFindings = snapshotsPerFile[file] ?: EMPTY

   Fix (Drop the shared snapshot and filter per rendered file at highlight time):
   // DirectHighlighter.collectHighlightPlans - compute for the file being rendered
   val criteria = getService(project, SonarLintToolWindow::class.java).getCurrentFileFilterCriteria()
       ?: FilterCriteria()
   val findings = FindingsFilter(project).filterAllFindings(file, criteria).getFindingsForFile(file)

2. ⚠️ Bug: Taint highlight test now fails: taints only come from the snapshot
   Files: src/test/java/org/sonarlint/intellij/editor/DirectHighlighterTests.kt:195-209, src/main/java/org/sonarlint/intellij/editor/DirectHighlighter.kt:244-258

   `should_highlight_taints_from_the_taint_cache_without_a_findings_store` seeds a taint only into `TaintVulnerabilitiesCache` (`seedTaint`) and asserts one highlight, but this commit removed the `TaintVulnerabilitiesCache` read from `collectHighlightPlans` — taints now come exclusively from `CurrentFileDisplayedFindingsStore`, whose snapshot is never set in that test, so `findings.taints` is empty and the `hasSize(1)` assertion fails. Either seed the store in `seedTaint` (and rename the test, since its premise "without a findings store" no longer holds) or keep the taint cache as a highlight source.

   Fix (Seed the displayed-findings store alongside the taint cache and rename the test to drop the "without a findings store" claim):
   private fun seedTaint(file: VirtualFile, content: String, message: String) {
       // ... existing taint construction ...
       getService(project, TaintVulnerabilitiesCache::class.java).taintVulnerabilities = listOf(taint)
       getService(project, CurrentFileDisplayedFindingsStore::class.java).setSnapshot(
           FilteredFindings(emptyList(), emptyList(), listOf(taint), emptyList()),
       )
   }

3. ⚠️ Bug: Panel re-gates highlight files by FindingsScope, contradicting the contract
   Files: src/main/java/org/sonarlint/intellij/ui/currentfile/CurrentFilePanel.kt:448-462, src/main/java/org/sonarlint/intellij/editor/EditorHighlightRefresh.kt:28-38

   `resolveEditorHighlightFiles` checks `filterCriteria.findingsScope == FindingsScope.CURRENT_FILE` *before* `highlightRefresh.changedFiles`, so with the default CURRENT_FILE scope a `refreshView(EditorHighlightRefresh.enabled(changedFiles))` collapses the file set to `listOfNotNull(file)` and every other open file keeps stale markup. This directly contradicts `EditorHighlightRefresh`'s own KDoc ("[changedFiles] ... honored whenever present", "the concrete set of files is resolved by OnTheFlyFindingsCoordinator, not the Current File panel") and the PR's stated contract of no `FindingsScope` on the highlight file set. Let the coordinator resolve the files (`applyHighlightRefresh(highlightRefresh)`) and drop the scope branch.

   Fix (Delegate file resolution to the coordinator instead of re-gating it by the panel's findings scope):
   private fun refreshEditorHighlights(highlightRefresh: EditorHighlightRefresh) {
       getService(project, OnTheFlyFindingsCoordinator::class.java).applyHighlightRefresh(highlightRefresh)
   }

4. ⚠️ Bug: Filter criteria read off the EDT from the analysis publish thread
   Files: src/main/java/org/sonarlint/intellij/ui/currentfile/CurrentFileDisplayedFindingsRefresher.kt:37-42, src/main/java/org/sonarlint/intellij/actions/SonarLintToolWindow.java:169-178, src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsHolder.kt:80-94, src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsHolder.kt:218-232

   `OnTheFlyFindingsHolder.updateViewsWithNewIssues` / `updateOnAnalysisResult` run on the backend RPC/analysis thread and now reach `coordinator.applyHighlightRefresh` → `refreshDisplayedFindings` → `SonarLintToolWindow.getCurrentFileFilterCriteria`, which touches `ToolWindowManager`/`ContentManager.findContent` and then reads live Swing state (`filtersPanel.filterText`, `quickFixCheckBox.isSelected`, `findingsScope`) from that background thread — a platform threading violation and a race with concurrent EDT filter edits. Read the criteria on the EDT (or cache the last criteria in a thread-safe holder the panel updates on the EDT) before computing the snapshot.

   Fix (Fetch the panel's filter criteria on the EDT before filtering off-thread):
   fun refreshDisplayedFindings(file: VirtualFile?) {
       val criteria = computeOnEdt(project) {
           getService(project, SonarLintToolWindow::class.java).getCurrentFileFilterCriteria() ?: FilterCriteria()
       }
       val filteredFindings = FindingsFilter(project).filterAllFindings(file, criteria)
       getService(project, CurrentFileDisplayedFindingsStore::class.java).setSnapshot(filteredFindings)
   }

5. ⚠️ Quality: Diff contradicts the PR's stated contract on filter decoupling
   Files: src/main/java/org/sonarlint/intellij/editor/DirectHighlighter.kt:240-245, src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsCoordinator.kt:33-41, src/test/java/org/sonarlint/intellij/editor/DirectHighlighterTests.kt:215-229

   The PR description's implementation contract states the highlighter reads unfiltered coordinator issues/hotspots plus `TaintVulnerabilitiesCache`, that `CurrentFileDisplayedFindingsStore` is deleted, and that "squiggles do not follow search/severity/scope"; this commit reinstates the store as the highlighter's only source and renames the guarding test to `should_hide_editor_squiggle_when_a_list_filter_hides_the_issue`, so a text filter in the Findings list once again hides editor squiggles. Either update the description/ticket scope to record this reversal as intentional, or restore the unfiltered read path so the decoupling the PR advertises actually holds.

   Fix (Align the stated contract with the code (or revert the code to the stated contract)):
   // Update the PR description's "Implementation contract" to state:
   // - Highlighter reads the filtered snapshot published by CurrentFileDisplayedFindingsRefresher
   // - Editor squiggles DO follow Current File tab filters (search/severity/scope)
   // ... or revert DirectHighlighter.collectHighlightPlans to the holder + TaintVulnerabilitiesCache read.

6. 💡 Performance: Highlight refresh and filtering run twice per resolve/CAYC/binding event
   Files: src/main/java/org/sonarlint/intellij/analysis/OnTheFlyFindingsCoordinator.kt:62-67, src/main/java/org/sonarlint/intellij/actions/SonarLintToolWindow.java:125-127, src/main/java/org/sonarlint/intellij/ui/currentfile/CurrentFilePanel.kt:441-453, src/main/java/org/sonarlint/intellij/ui/currentfile/CurrentFilePanel.kt:513-522

   `applyHighlightRefreshAndRefreshPanels` calls `applyHighlightRefresh` (snapshot rebuild + `refreshFiles`) and then `refreshViews()`, which now calls `panel.refreshView(EditorHighlightRefresh.enabled())` whose `finally` block calls `applyHighlightRefresh` again — so every mark-as-resolved, reopen, CAYC toggle and binding change re-filters all findings and re-queues markup twice, the second time on the EDT. Pass `EditorHighlightRefresh.NONE` from `refreshViews()` (the coordinator already handled markup) and keep the panel's highlight trigger for callers that only rebuild the panel.

   Fix (Let the coordinator own the markup pass so panel rebuilds do not duplicate it):
   public void refreshViews() {
     this.<CurrentFilePanel>updateCurrentFileTab(panel -> panel.refreshView(EditorHighlightRefresh.NONE));

Implementation Status ✅ 3 of 3 objectives covered
SLI-2850 - 3 of 3 objectives covered

This PR successfully implements all objectives by decoupling editor highlighting from the Current File tool window, introducing a dedicated findings store and coordinator service, and ensuring clean files correctly clear their highlights.

✅ 3 covered here
  • ✅ Ensure editor highlighting updates for files that just became clean
  • ✅ Implement a coordinator service to own analysis results and update editor highlighting and the tool window independently
  • ✅ Remove the dependency of editor highlighting on the Current File tool window existing or writing the highlighter snapshot

Tip

Comment Gitar fix CI or enable auto-apply: gitar auto-apply:on

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.
Unblock → Override a blocking verdict and allow merging.

Comment with these commands to change the behavior for this request:

Auto-apply Compact Unblock
gitar auto-apply:on         
gitar display:verbose         
gitar unblock         

Was this helpful? React with 👍 / 👎 | Gitar

@sonarqube-next

sonarqube-next Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Quality Gate failed Quality Gate failed

Failed conditions
1 New issue

See analysis details on SonarQube

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE SonarQube for IDE

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant