[gh] Dim comments when showing the on-hover outline

#IJPL-200029 Fixed

GitOrigin-RevId: 2c894f76709047b62fe26db035ee79c3589ef370
This commit is contained in:
Bartosz Janusz
2025-10-07 15:28:09 +00:00
committed by intellij-monorepo-bot
parent eb41f6abd9
commit 1c604ef58a
7 changed files with 138 additions and 39 deletions
@@ -2,10 +2,12 @@
package org.jetbrains.plugins.github.pullrequest.ui
import com.intellij.collaboration.async.combineState
import com.intellij.collaboration.async.flatMapLatestEach
import com.intellij.collaboration.async.launchNow
import com.intellij.collaboration.ui.codereview.diff.DiffLineLocation
import com.intellij.collaboration.ui.codereview.editor.CodeReviewCommentableEditorModel
import com.intellij.collaboration.ui.codereview.editor.CodeReviewEditorGutterControlsModel
import com.intellij.collaboration.ui.codereview.editor.CodeReviewEditorInlaysModel
import com.intellij.diff.util.LineRange
import com.intellij.diff.util.Side
import com.intellij.openapi.editor.Editor
@@ -19,13 +21,18 @@ import com.intellij.openapi.editor.markup.RangeHighlighter
import com.intellij.openapi.wm.IdeGlassPaneUtil
import com.intellij.platform.util.coroutines.childScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.jetbrains.plugins.github.pullrequest.ui.comment.CommentedCodeFrameRenderer
import org.jetbrains.plugins.github.pullrequest.ui.editor.GHPREditorMappedComponentModel
import java.awt.Cursor
import java.awt.Point
import java.awt.*
import javax.swing.JComponent
import javax.swing.JLayer
import javax.swing.plaf.LayerUI
import kotlin.math.abs
internal object GHPRInlayUtils {
@@ -78,9 +85,7 @@ internal object GHPRInlayUtils {
finally {
if (frameResizer != null) {
val editorEx = editor as EditorEx
val gutterGlassComp = IdeGlassPaneUtil.find(editorEx.gutterComponentEx)
editorEx.setCustomCursor(frameResizer, null)
gutterGlassComp.setCursor(null, frameResizer)
editor.removeEditorMouseListener(frameResizer)
editor.removeEditorMouseMotionListener(frameResizer)
}
@@ -106,7 +111,6 @@ internal object GHPRInlayUtils {
Cursor.getDefaultCursor()
}
override fun mouseDragged(e: EditorMouseEvent) {
if (!isDraggingFrame || edge == null || oldRange == null) return
e.consume() // to prevent selecting text while dragging
@@ -203,4 +207,61 @@ internal object GHPRInlayUtils {
TOP, BOTTOM
}
}
@OptIn(ExperimentalCoroutinesApi::class)
fun installInlaysDimming(cs: CoroutineScope, model: CodeReviewEditorInlaysModel<*>) {
cs.launchNow {
model.inlays
.map { it.filterIsInstance<GHPREditorMappedComponentModel>() }
.flatMapLatestEach { item ->
combine(item.shouldShowOutline, item.range) { shouldShowOutline, range ->
InlayState(item, shouldShowOutline, range)
}
}
.collectLatest { inlayStates ->
val rangesToDim = inlayStates
.filter { it.shouldShowOutline }
.mapNotNull {
val (_, lines) = it.range ?: return@mapNotNull null
lines.first..<lines.last
}
inlayStates.forEach { (vm, _, range) ->
val (_, lines) = range ?: return@forEach
val onLine = lines.last
vm.setDimmed(rangesToDim.any { dimRange -> dimRange.contains(onLine) })
}
}
}
}
private data class InlayState(
val inlay: GHPREditorMappedComponentModel,
val shouldShowOutline: Boolean,
val range: Pair<Side, IntRange>?,
)
}
internal class FadeLayerUI : LayerUI<JComponent>() {
private var alpha: Float = 1f
fun setAlpha(a: Float, jLayer: JLayer<JComponent>) {
alpha = a.coerceIn(0f, 1f)
jLayer.repaint()
}
override fun paint(g: Graphics, c: JComponent) {
val g2 = g.create() as Graphics2D
try {
val old = g2.composite
g2.composite = AlphaComposite.SrcOver.derive(alpha)
super.paint(g2, c)
g2.composite = old
}
finally {
g2.dispose()
}
}
}
@@ -6,4 +6,6 @@ import kotlinx.coroutines.flow.StateFlow
interface GHPRHoverableReviewComment {
val shouldShowOutline: StateFlow<Boolean>
fun showOutline(isHovered: Boolean)
val isDimmed: StateFlow<Boolean>
fun setDimmed(isDimmed: Boolean)
}
@@ -0,0 +1,22 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.plugins.github.pullrequest.ui.comment
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class GHPRHoverableReviewCommentImpl : GHPRHoverableReviewComment {
private val _isHovered: MutableStateFlow<Boolean> = MutableStateFlow(false)
override val shouldShowOutline: StateFlow<Boolean> = _isHovered.asStateFlow()
override fun showOutline(isHovered: Boolean) {
_isHovered.value = isHovered
}
private val _isDimmed: MutableStateFlow<Boolean> = MutableStateFlow(false)
override val isDimmed: StateFlow<Boolean> = _isDimmed.asStateFlow()
override fun setDimmed(isDimmed: Boolean) {
_isDimmed.value = isDimmed
}
}
@@ -100,11 +100,15 @@ internal class GHPRReviewDiffExtension : DiffExtension() {
val (leftLine, rightLine) = lineToUnified(it)
GHPRReviewUnifiedPosition(change, leftLine, rightLine)
}.apply {
editor.putUserData(GHPRReviewDiffEditorModel.KEY, this)
cs.launchNow {
inlays
.mapStatefulToStateful { inlayModel -> GHPRInlayUtils.installInlayHoverOutline(this, editor, side, locationToLine, inlayModel) }
.mapStatefulToStateful { inlayModel ->
GHPRInlayUtils.installInlayHoverOutline(this, editor, side, locationToLine, inlayModel)
}
.collect()
}
GHPRInlayUtils.installInlaysDimming(cs, this@apply)
}
}
}
@@ -121,6 +121,7 @@ private suspend fun showReview(project: Project, settings: GithubPullRequestsPro
launchNow {
val userIcon = fileVm.iconProvider.getIcon(fileVm.currentUser.url, 16)
editor.renderInlays(model.inlays, HashingUtil.mappingStrategy(GHPREditorMappedComponentModel::key)) {
GHPRInlayUtils.installInlaysDimming(this, model)
launchNow {
model.inlays
.mapStatefulToStateful { inlayModel -> GHPRInlayUtils.installInlayHoverOutline(this, editor, Side.RIGHT, null, inlayModel) }
@@ -12,36 +12,25 @@ import kotlinx.coroutines.flow.asStateFlow
import org.jetbrains.plugins.github.ai.GHPRAICommentViewModel
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRCompactReviewThreadViewModel
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRHoverableReviewComment
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRHoverableReviewCommentImpl
import javax.swing.Icon
internal sealed interface GHPREditorMappedComponentModel : CodeReviewInlayModel, GHPRHoverableReviewComment {
val range: StateFlow<Pair<Side, IntRange>?>
abstract class Thread<VM : GHPRCompactReviewThreadViewModel>(val vm: VM)
: GHPREditorMappedComponentModel, Hideable {
: GHPREditorMappedComponentModel, Hideable,
GHPRHoverableReviewComment by GHPRHoverableReviewCommentImpl() {
final override val key: Any = vm.id
final override val hiddenState = MutableStateFlow(false)
final override fun setHidden(hidden: Boolean) {
hiddenState.value = hidden
}
private val _isHovered: MutableStateFlow<Boolean> = MutableStateFlow(false)
override val shouldShowOutline: StateFlow<Boolean> = _isHovered.asStateFlow()
override fun showOutline(isHovered: Boolean) {
_isHovered.value = isHovered
}
}
abstract class NewComment<VM : GHPRReviewNewCommentEditorViewModel>(val vm: VM) : GHPREditorMappedComponentModel {
private val _isHovered: MutableStateFlow<Boolean> = MutableStateFlow(false)
override val shouldShowOutline: StateFlow<Boolean> = _isHovered.asStateFlow()
override fun showOutline(isHovered: Boolean) {
_isHovered.value = isHovered
}
abstract class NewComment<VM : GHPRReviewNewCommentEditorViewModel>(val vm: VM)
: GHPREditorMappedComponentModel, GHPRHoverableReviewComment by GHPRHoverableReviewCommentImpl() {
abstract fun setRange(range: Pair<Side, IntRange>?)
private val _isHidden: MutableStateFlow<Boolean> = MutableStateFlow(false)
val isHidden: StateFlow<Boolean> = _isHidden.asStateFlow()
fun isHidden(hidden: Boolean) {
@@ -50,19 +39,12 @@ internal sealed interface GHPREditorMappedComponentModel : CodeReviewInlayModel,
}
abstract class AIComment(val vm: GHPRAICommentViewModel)
: GHPREditorMappedComponentModel, Hideable {
: GHPREditorMappedComponentModel, Hideable, GHPRHoverableReviewComment by GHPRHoverableReviewCommentImpl() {
final override val key: Any = vm.key
final override val hiddenState = MutableStateFlow(false)
final override fun setHidden(hidden: Boolean) {
hiddenState.value = hidden
}
private val _isHovered: MutableStateFlow<Boolean> = MutableStateFlow(false)
override val shouldShowOutline: StateFlow<Boolean> = _isHovered.asStateFlow()
override fun showOutline(isHovered: Boolean) {
_isHovered.value = isHovered
}
}
}
@@ -71,4 +53,4 @@ internal fun CoroutineScope.createRenderer(model: GHPREditorMappedComponentModel
is GHPREditorMappedComponentModel.Thread<*> -> GHPRReviewThreadEditorInlayRenderer(this, model, model.vm)
is GHPREditorMappedComponentModel.NewComment<*> -> GHPRNewCommentEditorInlayRenderer(this, model, model.vm)
is GHPREditorMappedComponentModel.AIComment -> GHPRAICommentEditorInlayRenderer(userIcon, model.vm)
}
}
@@ -4,17 +4,22 @@ package org.jetbrains.plugins.github.pullrequest.ui.editor
import com.intellij.collaboration.async.launchNow
import com.intellij.collaboration.ui.codereview.editor.CodeReviewComponentInlayRenderer
import com.intellij.collaboration.ui.util.bindContent
import com.intellij.openapi.application.EDT
import com.intellij.openapi.observable.util.addMouseHoverListener
import com.intellij.ui.components.panels.Wrapper
import com.intellij.ui.hover.HoverStateListener
import com.intellij.util.ui.launchOnShow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.plugins.github.ai.GHPRAICommentViewModel
import org.jetbrains.plugins.github.ai.GHPRAIReviewExtension
import org.jetbrains.plugins.github.pullrequest.ui.FadeLayerUI
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRCompactReviewThreadViewModel
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRHoverableReviewComment
import java.awt.Component
import javax.swing.Icon
import javax.swing.JLayer
@ApiStatus.Internal
class GHPRReviewThreadEditorInlayRenderer internal constructor(
@@ -22,8 +27,19 @@ class GHPRReviewThreadEditorInlayRenderer internal constructor(
hoverableVm: GHPRHoverableReviewComment,
vm: GHPRCompactReviewThreadViewModel,
) : CodeReviewComponentInlayRenderer(
GHPRReviewEditorComponentsFactory.createThreadIn(cs, vm).apply {
addMouseHoverListener(null, MouseOverInlayListener(hoverableVm))
run {
val fadeLayerUI = FadeLayerUI()
val layer = JLayer(
GHPRReviewEditorComponentsFactory.createThreadIn(cs, vm).apply {
addMouseHoverListener(null, MouseOverInlayListener(hoverableVm))
}, fadeLayerUI
)
layer.launchOnShow("Inlay.Dimming.${vm::javaClass.name}", Dispatchers.EDT) {
hoverableVm.isDimmed.collect { isDimmed: Boolean ->
fadeLayerUI.setAlpha(if (isDimmed) 0.5f else 1f, layer)
}
}
layer
}
)
@@ -34,13 +50,23 @@ class GHPRNewCommentEditorInlayRenderer internal constructor(
hoverableVm: GHPRHoverableReviewComment,
vm: GHPRReviewNewCommentEditorViewModel,
) : CodeReviewComponentInlayRenderer(
GHPRReviewEditorComponentsFactory.createNewCommentIn(cs, vm).also { comp ->
hoverableVm.showOutline(true)
cs.launchNow {
(hoverableVm as GHPREditorMappedComponentModel.NewComment<*>).isHidden.collect {
comp.isVisible = !it
run {
val fadeLayerUI = FadeLayerUI()
val layer = JLayer(
GHPRReviewEditorComponentsFactory.createNewCommentIn(cs, vm).also {
hoverableVm.showOutline(true)
}, fadeLayerUI)
layer.launchOnShow("Inlay.Dimming.${vm::javaClass.name}", Dispatchers.EDT) {
hoverableVm.isDimmed.collect { isDimmed: Boolean ->
fadeLayerUI.setAlpha(if (isDimmed) 0.5f else 1f, layer)
}
}
cs.launchNow {
(hoverableVm as GHPREditorMappedComponentModel.NewComment<*>).isHidden.collect {
layer.isVisible = !it
}
}
layer
}
)
@@ -52,8 +78,9 @@ internal class GHPRAICommentEditorInlayRenderer internal constructor(userIcon: I
}
})
private class MouseOverInlayListener(val vm: GHPRHoverableReviewComment) : HoverStateListener() {
override fun hoverChanged(component: Component, hovered: Boolean) {
vm.showOutline(hovered)
}
}
}