[gitlab] avoid timeline relayout on model changes

GitOrigin-RevId: d18d5df2997b8d0298fa44dcd9a90d75e57e5ca8
This commit is contained in:
Ivan Semenov
2023-01-20 16:59:59 +01:00
committed by intellij-monorepo-bot
parent de3ff8eea8
commit df744a30eb
15 changed files with 470 additions and 290 deletions

View File

@@ -2,6 +2,7 @@
package com.intellij.collaboration.async
import com.intellij.openapi.Disposable
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.util.Disposer
import com.intellij.util.childScope
import kotlinx.coroutines.*
@@ -145,4 +146,52 @@ suspend fun <T> Flow<T>.collectWithPrevious(initial: T, collector: suspend (prev
collector(prev, it)
prev = it
}
}
}
/**
* Lazy shared flow that logs all exceptions as errors and never throws (beside cancellation)
*/
fun <T> Flow<T>.modelFlow(cs: CoroutineScope, log: Logger): SharedFlow<T> =
catch { log.error(it) }.shareIn(cs, SharingStarted.Lazily, 1)
fun <ID : Any, T, R> Flow<List<T>>.mapCaching(sourceIdentifier: (T) -> ID,
mapper: suspend (T) -> R,
destroy: suspend R.() -> Unit,
update: (suspend R.(T) -> Unit)? = null): Flow<List<R>> {
var initial = true
val result = LinkedHashMap<ID, R>()
return transform { items ->
var hasStructureChanges = false
val itemsById = items.associateBy(sourceIdentifier)
// remove missing
val iter = result.iterator()
while (iter.hasNext()) {
val (key, exisingResult) = iter.next()
if (!itemsById.containsKey(key)) {
iter.remove()
hasStructureChanges = true
exisingResult.destroy()
}
}
// add new or update existing
for (item in items) {
val id = sourceIdentifier(item)
val existing = result[id]
if (existing != null && update != null) {
existing.update(item)
}
else {
result[id] = mapper(item)
hasStructureChanges = true
}
}
if (hasStructureChanges || initial) {
initial = false
emit(result.values.toList())
}
}
}

View File

@@ -1,6 +1,12 @@
// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.collaboration.ui
import com.intellij.openapi.util.Key
import com.intellij.ui.ClientProperty
import com.intellij.util.childScope
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import java.util.*
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.ListModel
@@ -46,4 +52,76 @@ object ComponentListPanelFactory {
return panel
}
// NOTE: new items are ALWAYS added to the end
fun <T : Any> createVertical(parentCs: CoroutineScope,
items: Flow<List<T>>,
itemKeyExtractor: ((T) -> Any),
gap: Int,
componentFactory: suspend (CoroutineScope, T) -> JComponent): JPanel {
val cs = parentCs.childScope(Dispatchers.Main)
val panel = VerticalListPanel(gap)
val keyList = LinkedList<Any>()
suspend fun addComponent(idx: Int, item: T) {
withContext(Dispatchers.Main.immediate) {
val scope = cs.childScope()
val component = componentFactory(scope, item).also {
ClientProperty.put(it, COMPONENT_SCOPE_KEY, scope)
}
panel.add(component, idx)
}
}
suspend fun removeComponent(idx: Int) {
withContext(Dispatchers.Main.immediate) {
val component = panel.getComponent(idx)
val componentCs = ClientProperty.get(component, COMPONENT_SCOPE_KEY)
cs.launch {
componentCs?.coroutineContext?.get(Job)?.cancelAndJoin()
}
panel.remove(idx)
}
}
cs.launch(Dispatchers.Default, start = CoroutineStart.UNDISPATCHED) {
items.collect { items ->
val itemsByKey = items.associateBy(itemKeyExtractor)
// remove missing
val iter = keyList.iterator()
var keyIdx = 0
while (iter.hasNext()) {
val key = iter.next()
if (!itemsByKey.containsKey(key)) {
iter.remove()
removeComponent(keyIdx)
}
else {
keyIdx++
}
}
//add new
val keySet = keyList.toMutableSet()
for (item in items) {
val key = itemKeyExtractor(item)
if (keySet.contains(key)) continue
val idx = keyList.size
keyList.add(key)
keySet.add(key)
addComponent(idx, item)
}
withContext(Dispatchers.Main.immediate) {
panel.revalidate()
panel.repaint()
}
}
}
return panel
}
private val COMPONENT_SCOPE_KEY = Key.create<CoroutineScope>("ComponentListPanelFactory.Component.Scope")
}

View File

@@ -1,6 +1,8 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.plugins.gitlab.mergerequest.data
import com.intellij.collaboration.async.mapCaching
import com.intellij.collaboration.async.modelFlow
import com.intellij.openapi.diagnostic.logger
import com.intellij.util.childScope
import kotlinx.coroutines.*
@@ -26,64 +28,67 @@ interface GitLabDiscussion {
suspend fun changeResolvedState()
}
@OptIn(ExperimentalCoroutinesApi::class)
private val LOG = logger<GitLabDiscussion>()
class LoadedGitLabDiscussion(
parentCs: CoroutineScope,
private val connection: GitLabProjectConnection,
private val eventSink: suspend (GitLabDiscussionEvent) -> Unit,
private val discussion: GitLabDiscussionDTO
private val discussionData: GitLabDiscussionDTO
) : GitLabDiscussion {
init {
require(discussion.notes.isNotEmpty()) { "Discussion with empty notes" }
require(discussionData.notes.isNotEmpty()) { "Discussion with empty notes" }
}
override val id: String = discussion.id
override val createdAt: Date = discussion.createdAt
override val id: String = discussionData.id
override val createdAt: Date = discussionData.createdAt
private val cs = parentCs.childScope(CoroutineExceptionHandler { _, e ->
logger<GitLabDiscussion>().info(e.localizedMessage)
})
private val cs = parentCs.childScope(CoroutineExceptionHandler { _, e -> LOG.warn(e) })
private val operationsGuard = Mutex()
private val events = MutableSharedFlow<GitLabNoteEvent>()
private val loadedNotes = MutableSharedFlow<List<GitLabNoteDTO>>(1).apply {
tryEmit(discussion.notes)
}
private val noteEvents = MutableSharedFlow<GitLabNoteEvent>()
private val loadedNotes = channelFlow {
val notesData = discussionData.notes.toMutableList()
override val notes: Flow<List<GitLabNote>> = loadedNotes.transformLatest<List<GitLabNoteDTO>, List<GitLabNote>> { loadedNotes ->
coroutineScope {
val notesCs = this
val notes = Collections.synchronizedMap(LinkedHashMap<String, LoadedGitLabNote>())
loadedNotes.associateByTo(notes, GitLabNoteDTO::id) { noteData ->
LoadedGitLabNote(notesCs, connection, { events.emit(it) }, noteData)
}
emit(notes.values.toList())
events.collectLatest {
when (it) {
is GitLabNoteEvent.NoteDeleted -> {
notes.remove(it.noteId)?.destroy()
launch(start = CoroutineStart.UNDISPATCHED) {
noteEvents.collectLatest { event ->
when (event) {
is GitLabNoteEvent.Deleted -> notesData.removeIf { it.id == event.noteId }
is GitLabNoteEvent.NotesChanged -> {
notesData.clear()
notesData.addAll(event.notes)
}
}
emit(notes.values.toList())
}
awaitCancellation()
}
}.transform {
if (it.isEmpty()) {
eventSink(GitLabDiscussionEvent.DiscussionDeleted(id))
currentCoroutineContext().cancel()
}
emit(it)
}.shareIn(cs, SharingStarted.Lazily, 1)
if (notesData.isEmpty()) {
eventSink(GitLabDiscussionEvent.Deleted(discussionData.id))
return@collectLatest
}
private val firstNote = loadedNotes.map { it.first() }
send(Collections.unmodifiableList(notesData))
}
}
send(Collections.unmodifiableList(notesData))
}.modelFlow(cs, LOG)
override val notes: Flow<List<GitLabNote>> =
loadedNotes
.mapCaching(
GitLabNoteDTO::id,
{ LoadedGitLabNote(cs, connection, { noteEvents.emit(it) }, it) },
LoadedGitLabNote::destroy,
LoadedGitLabNote::update
)
.modelFlow(cs, LOG)
// a little cheat that greatly simplifies the implementation
override val canResolve: Boolean = discussion.notes.first().let { it.resolvable && it.userPermissions.resolveNote }
override val resolved: Flow<Boolean> = firstNote.map { it.resolved }
override val canResolve: Boolean = discussionData.notes.first().let { it.resolvable && it.userPermissions.resolveNote }
override val resolved: Flow<Boolean> =
loadedNotes
.map { it.first().resolved }
.distinctUntilChanged()
.modelFlow(cs, LOG)
override suspend fun changeResolvedState() {
withContext(cs.coroutineContext) {
@@ -91,18 +96,21 @@ class LoadedGitLabDiscussion(
val resolved = resolved.first()
val result = withContext(Dispatchers.IO) {
connection.apiClient
.changeMergeRequestDiscussionResolve(connection.repo.repository, discussion.id, !resolved).getResultOrThrow()
.changeMergeRequestDiscussionResolve(connection.repo.repository, discussionData.id, !resolved).getResultOrThrow()
}
updateNotes(result.notes)
noteEvents.emit(GitLabNoteEvent.NotesChanged(result.notes))
}
}
}
private suspend fun updateNotes(notes: List<GitLabNoteDTO>) {
loadedNotes.emit(notes)
suspend fun destroy() {
try {
cs.coroutineContext[Job]!!.cancelAndJoin()
}
catch (e: CancellationException) {
// ignore, cuz we don't want to cancel the invoker
}
}
suspend fun destroy() {
cs.coroutineContext[Job]!!.cancelAndJoin()
}
override fun toString(): String = "LoadedGitLabDiscussion(id='$id', createdAt=$createdAt, canResolve=$canResolve)"
}

View File

@@ -2,5 +2,5 @@
package org.jetbrains.plugins.gitlab.mergerequest.data
sealed interface GitLabDiscussionEvent {
class DiscussionDeleted(val discussionId: String) : GitLabDiscussionEvent
class Deleted(val discussionId: String) : GitLabDiscussionEvent
}

View File

@@ -3,60 +3,60 @@ package org.jetbrains.plugins.gitlab.mergerequest.data
import com.intellij.collaboration.api.page.ApiPageUtil
import com.intellij.collaboration.api.page.foldToList
import com.intellij.collaboration.async.mapCaching
import com.intellij.collaboration.async.modelFlow
import com.intellij.openapi.diagnostic.logger
import com.intellij.util.childScope
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.jetbrains.plugins.gitlab.api.GitLabProjectConnection
import org.jetbrains.plugins.gitlab.api.dto.GitLabDiscussionDTO
import org.jetbrains.plugins.gitlab.mergerequest.api.request.loadMergeRequestDiscussions
import java.util.concurrent.ConcurrentHashMap
//TODO: granualar collection changes notifications
interface GitLabMergeRequestDiscussionsModel {
val userDiscussions: Flow<Collection<GitLabDiscussion>>
val systemDiscussions: Flow<Collection<GitLabDiscussionDTO>>
}
@OptIn(ExperimentalCoroutinesApi::class)
private val LOG = logger<GitLabMergeRequestDiscussionsModel>()
class GitLabMergeRequestDiscussionsModelImpl(
parentCs: CoroutineScope,
private val connection: GitLabProjectConnection,
private val mr: GitLabMergeRequestId
) : GitLabMergeRequestDiscussionsModel {
private val cs = parentCs.childScope(Dispatchers.Default)
private val events = MutableSharedFlow<GitLabDiscussionEvent>(extraBufferCapacity = 64)
private val cs = parentCs.childScope(Dispatchers.Default + CoroutineExceptionHandler { _, e -> LOG.warn(e) })
private val nonEmptyDiscussionsData = flow {
emit(loadNonEmptyDiscussions())
}.shareIn(cs, SharingStarted.Lazily, 1)
override val userDiscussions: Flow<Collection<GitLabDiscussion>> = nonEmptyDiscussionsData.transformLatest { loadedDiscussions ->
coroutineScope {
val discussionsCs = this
val discussions = ConcurrentHashMap<String, LoadedGitLabDiscussion>()
loadedDiscussions.associateByTo(discussions, GitLabDiscussionDTO::id) { noteData ->
LoadedGitLabDiscussion(discussionsCs, connection, { events.emit(it) }, noteData)
}
emit(discussions.values.toList())
events.collectLatest {
when (it) {
is GitLabDiscussionEvent.DiscussionDeleted -> {
discussions.remove(it.discussionId)?.destroy()
private val discussionEvents = MutableSharedFlow<GitLabDiscussionEvent>()
private val nonEmptyDiscussionsData: Flow<List<GitLabDiscussionDTO>> =
channelFlow {
val discussions = loadNonEmptyDiscussions().toMutableList()
launch(start = CoroutineStart.UNDISPATCHED) {
discussionEvents.collectLatest { e ->
when (e) {
is GitLabDiscussionEvent.Deleted -> discussions.removeIf { it.id == e.discussionId }
}
send(discussions)
}
emit(discussions.values.toList())
}
send(discussions)
}.modelFlow(cs, LOG)
awaitCancellation()
}
}.shareIn(cs, SharingStarted.Lazily, 1)
override val userDiscussions: Flow<List<GitLabDiscussion>> =
nonEmptyDiscussionsData
.mapFiltered { !it.notes.first().system }
.mapCaching(
GitLabDiscussionDTO::id,
{ LoadedGitLabDiscussion(cs, connection, { discussionEvents.emit(it) }, it) },
LoadedGitLabDiscussion::destroy
)
.modelFlow(cs, LOG)
override val systemDiscussions: Flow<List<GitLabDiscussionDTO>> =
nonEmptyDiscussionsData.map { discussions ->
discussions.filter { it.notes.first().system }
}.shareIn(cs, SharingStarted.Lazily, 1)
nonEmptyDiscussionsData
.mapFiltered { it.notes.first().system }
.modelFlow(cs, LOG)
private suspend fun loadNonEmptyDiscussions(): List<GitLabDiscussionDTO> =
ApiPageUtil.createGQLPagesFlow {
@@ -64,4 +64,6 @@ class GitLabMergeRequestDiscussionsModelImpl(
}.map { discussions ->
discussions.filter { it.notes.isNotEmpty() }
}.foldToList()
private fun <T> Flow<List<T>>.mapFiltered(predicate: (T) -> Boolean): Flow<List<T>> = map { it.filter(predicate) }
}

View File

@@ -18,46 +18,48 @@ import org.jetbrains.plugins.gitlab.mergerequest.api.request.updateNote
import java.util.*
interface GitLabNote {
val id: String
val author: GitLabUserDTO
val createdAt: Date
val canAdmin: Boolean
val body: StateFlow<String>
suspend fun updateBody(newText: String)
suspend fun setBody(newText: String)
suspend fun delete()
suspend fun update(item: GitLabNoteDTO)
}
private val LOG = logger<GitLabDiscussion>()
class LoadedGitLabNote(
parentCs: CoroutineScope,
private val connection: GitLabProjectConnection,
private val eventSink: suspend (GitLabNoteEvent) -> Unit,
private val note: GitLabNoteDTO
private val noteData: GitLabNoteDTO
) : GitLabNote {
private val cs = parentCs.childScope(CoroutineExceptionHandler { _, e ->
logger<GitLabDiscussion>().info(e.localizedMessage)
})
private val cs = parentCs.childScope(CoroutineExceptionHandler { _, e -> LOG.warn(e) })
private val operationsGuard = Mutex()
override val author: GitLabUserDTO = note.author
override val createdAt: Date = note.createdAt
override val canAdmin: Boolean = note.userPermissions.adminNote
override val id: String = noteData.id
override val author: GitLabUserDTO = noteData.author
override val createdAt: Date = noteData.createdAt
override val canAdmin: Boolean = noteData.userPermissions.adminNote
private val _body = MutableStateFlow(note.body)
private val _body = MutableStateFlow(noteData.body)
override val body: StateFlow<String> = _body.asStateFlow()
override suspend fun updateBody(newText: String) {
override suspend fun setBody(newText: String) {
withContext(cs.coroutineContext) {
operationsGuard.withLock {
withContext(Dispatchers.IO) {
connection.apiClient.updateNote(connection.repo.repository, note.id, newText).getResultOrThrow()
connection.apiClient.updateNote(connection.repo.repository, noteData.id, newText).getResultOrThrow()
}
}
withContext(NonCancellable) {
_body.value = newText
}
_body.value = newText
}
}
@@ -65,16 +67,26 @@ class LoadedGitLabNote(
withContext(cs.coroutineContext) {
operationsGuard.withLock {
withContext(Dispatchers.IO) {
connection.apiClient.deleteNote(connection.repo.repository, note.id).getResultOrThrow()
connection.apiClient.deleteNote(connection.repo.repository, noteData.id).getResultOrThrow()
}
}
withContext(NonCancellable) {
eventSink(GitLabNoteEvent.NoteDeleted(note.id))
}
eventSink(GitLabNoteEvent.Deleted(noteData.id))
}
}
suspend fun destroy() {
cs.coroutineContext[Job]!!.cancelAndJoin()
override suspend fun update(item: GitLabNoteDTO) {
_body.value = item.body
}
suspend fun destroy() {
try {
cs.coroutineContext[Job]!!.cancelAndJoin()
}
catch (ce: CancellationException) {
// ignore, cuz we don't want to cancel the invoker
}
}
override fun toString(): String =
"LoadedGitLabNote(id='$id', author=$author, createdAt=$createdAt, body=${body.value})"
}

View File

@@ -1,6 +1,9 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.plugins.gitlab.mergerequest.data
import org.jetbrains.plugins.gitlab.api.dto.GitLabNoteDTO
sealed interface GitLabNoteEvent {
class NoteDeleted(val noteId: String) : GitLabNoteEvent
class NotesChanged(val notes: List<GitLabNoteDTO>) : GitLabNoteEvent
class Deleted(val noteId: String) : GitLabNoteEvent
}

View File

@@ -2,10 +2,7 @@
package org.jetbrains.plugins.gitlab.mergerequest.ui.timeline
import com.intellij.collaboration.async.mapScoped
import com.intellij.collaboration.ui.CollaborationToolsUIUtil
import com.intellij.collaboration.ui.SimpleHtmlPane
import com.intellij.collaboration.ui.TransparentScrollPane
import com.intellij.collaboration.ui.VerticalListPanel
import com.intellij.collaboration.ui.*
import com.intellij.collaboration.ui.codereview.CodeReviewChatItemUIUtil
import com.intellij.collaboration.ui.codereview.timeline.StatusMessageComponentFactory
import com.intellij.collaboration.ui.codereview.timeline.StatusMessageType
@@ -51,11 +48,8 @@ object GitLabMergeRequestTimelineComponentFactory {
SimpleHtmlPane(state.exception.localizedMessage)
}
is LoadingState.Result -> {
val itemScope = this
VerticalListPanel(0).apply {
for (item in state.items) {
add(createItemComponent(project, itemScope, avatarIconsProvider, item))
}
ComponentListPanelFactory.createVertical(this, state.items, GitLabMergeRequestTimelineItemViewModel::id, 0) { cs, item ->
createItemComponent(project, cs, avatarIconsProvider, item)
}.let {
TransparentScrollPane(it)
}
@@ -70,7 +64,7 @@ object GitLabMergeRequestTimelineComponentFactory {
return panel.also {
UiNotifyConnector.doWhenFirstShown(it) {
vm.startLoading()
vm.requestLoad()
}
}
}
@@ -81,12 +75,13 @@ object GitLabMergeRequestTimelineComponentFactory {
item: GitLabMergeRequestTimelineItemViewModel): JComponent =
when (item) {
is GitLabMergeRequestTimelineItemViewModel.Immutable -> {
val content = createContent(item)
val immutatebleItem = item.item
val content = createContent(immutatebleItem)
CodeReviewChatItemUIUtil.build(CodeReviewChatItemUIUtil.ComponentType.FULL,
{ avatarIconsProvider.getIcon(item.actor, it) },
{ avatarIconsProvider.getIcon(immutatebleItem.actor, it) },
content) {
withHeader(createTitleTextPane(item.actor, item.date))
withHeader(createTitleTextPane(immutatebleItem.actor, immutatebleItem.date))
}
}
is GitLabMergeRequestTimelineItemViewModel.Discussion -> {
@@ -94,15 +89,15 @@ object GitLabMergeRequestTimelineComponentFactory {
}
}
private fun createContent(item: GitLabMergeRequestTimelineItemViewModel.Immutable): JComponent =
private fun createContent(item: GitLabMergeRequestTimelineItem.Immutable): JComponent =
when (item) {
is GitLabMergeRequestTimelineItemViewModel.SystemDiscussion -> createSystemDiscussionContent(item)
is GitLabMergeRequestTimelineItemViewModel.LabelEvent -> createLabeledEventContent(item)
is GitLabMergeRequestTimelineItemViewModel.MilestoneEvent -> createMilestonedEventContent(item)
is GitLabMergeRequestTimelineItemViewModel.StateEvent -> createStateChangeContent(item)
is GitLabMergeRequestTimelineItem.SystemDiscussion -> createSystemDiscussionContent(item)
is GitLabMergeRequestTimelineItem.LabelEvent -> createLabeledEventContent(item)
is GitLabMergeRequestTimelineItem.MilestoneEvent -> createMilestonedEventContent(item)
is GitLabMergeRequestTimelineItem.StateEvent -> createStateChangeContent(item)
}
private fun createSystemDiscussionContent(item: GitLabMergeRequestTimelineItemViewModel.SystemDiscussion): JComponent {
private fun createSystemDiscussionContent(item: GitLabMergeRequestTimelineItem.SystemDiscussion): JComponent {
val content = item.content
if (content.contains("Compare with previous version")) {
try {
@@ -121,7 +116,7 @@ object GitLabMergeRequestTimelineComponentFactory {
return StatusMessageComponentFactory.create(SimpleHtmlPane(GitLabUIUtil.convertToHtml(content)))
}
private fun createLabeledEventContent(item: GitLabMergeRequestTimelineItemViewModel.LabelEvent): JComponent {
private fun createLabeledEventContent(item: GitLabMergeRequestTimelineItem.LabelEvent): JComponent {
val text = when (item.event.actionEnum) {
GitLabResourceLabelEventDTO.Action.ADD -> GitLabBundle.message("merge.request.event.label.added", item.event.label.toHtml())
GitLabResourceLabelEventDTO.Action.REMOVE -> GitLabBundle.message("merge.request.event.label.removed", item.event.label.toHtml())
@@ -130,7 +125,7 @@ object GitLabMergeRequestTimelineComponentFactory {
return StatusMessageComponentFactory.create(textPane)
}
private fun createMilestonedEventContent(item: GitLabMergeRequestTimelineItemViewModel.MilestoneEvent): JComponent {
private fun createMilestonedEventContent(item: GitLabMergeRequestTimelineItem.MilestoneEvent): JComponent {
val text = when (item.event.actionEnum) {
GitLabResourceMilestoneEventDTO.Action.ADD ->
GitLabBundle.message("merge.request.event.milestone.changed", item.event.milestone.toHtml())
@@ -141,7 +136,7 @@ object GitLabMergeRequestTimelineComponentFactory {
return StatusMessageComponentFactory.create(textPane)
}
private fun createStateChangeContent(item: GitLabMergeRequestTimelineItemViewModel.StateEvent): JComponent {
private fun createStateChangeContent(item: GitLabMergeRequestTimelineItem.StateEvent): JComponent {
val text = when (item.event.stateEnum) {
GitLabResourceStateEventDTO.State.CLOSED -> GitLabBundle.message("merge.request.event.closed")
GitLabResourceStateEventDTO.State.REOPENED -> GitLabBundle.message("merge.request.event.reopened")

View File

@@ -40,12 +40,12 @@ object GitLabMergeRequestTimelineDiscussionComponentFactory {
fun create(project: Project,
cs: CoroutineScope,
avatarIconsProvider: IconsProvider<GitLabUserDTO>,
item: GitLabMergeRequestTimelineItemViewModel.Discussion): JComponent {
val repliesActionsPanel = createRepliesActionsPanel(cs, avatarIconsProvider, item).apply {
discussion: GitLabMergeRequestTimelineDiscussionViewModel): JComponent {
val repliesActionsPanel = createRepliesActionsPanel(cs, avatarIconsProvider, discussion).apply {
border = JBUI.Borders.empty(Replies.ActionsFolded.VERTICAL_PADDING, 0)
bindVisibility(cs, item.repliesFolded)
bindVisibility(cs, discussion.repliesFolded)
}
val mainNoteVm = item.mainNote
val mainNoteVm = discussion.mainNote
val textPanel = createNoteTextPanel(cs, mainNoteVm.flatMapLatest { it.htmlBody })
// oh well... probably better to make a suitable API in EditableComponentFactory, but that would look ugly
@@ -66,30 +66,16 @@ object GitLabMergeRequestTimelineDiscussionComponentFactory {
val actionsPanel = createNoteActions(cs, mainNoteVm)
val repliesPanel = VerticalListPanel().apply {
cs.launch {
item.replies.combine(item.repliesFolded) { replies, folded ->
if (!folded) replies else emptyList()
}.collectLatest { notes ->
coroutineScope {
val notesScope = this
removeAll()
notes.forEach {
add(createNoteItem(project, notesScope, avatarIconsProvider, it))
}
revalidate()
repaint()
awaitCancellation()
}
}
}
bindVisibility(cs, item.repliesFolded.inverted())
val repliesPanel = ComponentListPanelFactory.createVertical(cs, discussion.replies, GitLabNoteViewModel::id, 0) { noteCs, noteVm ->
createNoteItem(project, noteCs, avatarIconsProvider, noteVm)
}.apply {
bindVisibility(cs, discussion.repliesFolded.inverted())
}
return CodeReviewChatItemUIUtil.buildDynamic(CodeReviewChatItemUIUtil.ComponentType.FULL,
{ item.author.createIconValue(cs, avatarIconsProvider, it) },
{ discussion.author.createIconValue(cs, avatarIconsProvider, it) },
contentPanel) {
withHeader(createTitleTextPane(cs, item.author, item.date), actionsPanel)
withHeader(createTitleTextPane(cs, discussion.author, discussion.date), actionsPanel)
}.let {
VerticalListPanel().apply {
add(it)
@@ -109,7 +95,7 @@ object GitLabMergeRequestTimelineDiscussionComponentFactory {
private fun createRepliesActionsPanel(cs: CoroutineScope,
avatarIconsProvider: IconsProvider<GitLabUserDTO>,
item: GitLabMergeRequestTimelineItemViewModel.Discussion): JComponent {
item: GitLabMergeRequestTimelineDiscussionViewModel): JComponent {
val authorsLabel = JLabel().apply {
bindVisibility(cs, item.replies.map { it.isNotEmpty() })

View File

@@ -1,9 +1,12 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.plugins.gitlab.mergerequest.ui.timeline
import com.intellij.collaboration.async.mapCaching
import com.intellij.collaboration.async.mapScoped
import com.intellij.collaboration.async.modelFlow
import com.intellij.openapi.diagnostic.logger
import com.intellij.util.childScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.jetbrains.plugins.gitlab.api.dto.GitLabUserDTO
import org.jetbrains.plugins.gitlab.mergerequest.data.GitLabDiscussion
@@ -12,8 +15,10 @@ import org.jetbrains.plugins.gitlab.mergerequest.ui.comment.GitLabMergeRequestDi
import org.jetbrains.plugins.gitlab.mergerequest.ui.comment.GitLabMergeRequestDiscussionResolveViewModelImpl
import org.jetbrains.plugins.gitlab.ui.comment.GitLabNoteViewModel
import org.jetbrains.plugins.gitlab.ui.comment.GitLabNoteViewModelImpl
import java.util.*
interface GitLabMergeRequestTimelineDiscussionViewModel {
val date: Date
val author: Flow<GitLabUserDTO>
val mainNote: Flow<GitLabNoteViewModel>
@@ -24,28 +29,39 @@ interface GitLabMergeRequestTimelineDiscussionViewModel {
val resolvedVm: GitLabMergeRequestDiscussionResolveViewModel?
fun setRepliesFolded(folded: Boolean)
suspend fun destroy()
}
private val LOG = logger<GitLabMergeRequestTimelineDiscussionViewModel>()
class GitLabMergeRequestTimelineDiscussionViewModelImpl(
parentCs: CoroutineScope,
discussion: GitLabDiscussion
) : GitLabMergeRequestTimelineDiscussionViewModel {
private val cs = parentCs.childScope()
private val cs = parentCs.childScope(CoroutineExceptionHandler { _, e -> LOG.warn(e) })
override val mainNote: Flow<GitLabNoteViewModel> = discussion.notes.mapScoped {
createNoteVm(this, it.first())
}.share()
override val mainNote: Flow<GitLabNoteViewModel> = discussion.notes
.map { it.first() }
.distinctUntilChangedBy { it.id }
.mapScoped { GitLabNoteViewModelImpl(this, it) }
.modelFlow(cs, LOG)
override val date: Date = discussion.createdAt
override val author: Flow<GitLabUserDTO> = mainNote.map { it.author }
private val _repliesFolded = MutableStateFlow(true)
override val repliesFolded: Flow<Boolean> = _repliesFolded.asStateFlow()
override val replies: Flow<List<GitLabNoteViewModel>> =
discussion.notes.mapScoped { notesList ->
notesList.asSequence().drop(1).map { createNoteVm(this, it) }.toList()
}.share()
override val author: Flow<GitLabUserDTO> = mainNote.map { it.author }
override val replies: Flow<List<GitLabNoteViewModel>> = discussion.notes
.map { it.drop(1) }
.mapCaching(
GitLabNote::id,
{ GitLabNoteViewModelImpl(cs, it) },
GitLabNoteViewModelImpl::destroy
)
.modelFlow(cs, LOG)
override val resolvedVm: GitLabMergeRequestDiscussionResolveViewModel? =
if (discussion.canResolve) GitLabMergeRequestDiscussionResolveViewModelImpl(cs, discussion) else null
@@ -54,8 +70,12 @@ class GitLabMergeRequestTimelineDiscussionViewModelImpl(
_repliesFolded.value = folded
}
private fun createNoteVm(parentCs: CoroutineScope, note: GitLabNote): GitLabNoteViewModel =
GitLabNoteViewModelImpl(parentCs, note)
private fun <T> Flow<T>.share() = shareIn(cs, SharingStarted.Lazily, 1)
override suspend fun destroy() {
try {
cs.coroutineContext[Job]!!.cancelAndJoin()
}
catch (e: CancellationException) {
// ignore, cuz we don't want to cancel the invoker
}
}
}

View File

@@ -0,0 +1,60 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.plugins.gitlab.mergerequest.ui.timeline
import org.jetbrains.plugins.gitlab.api.dto.*
import org.jetbrains.plugins.gitlab.mergerequest.data.GitLabDiscussion
import java.util.*
sealed interface GitLabMergeRequestTimelineItem {
val id: String
val date: Date
sealed interface Immutable : GitLabMergeRequestTimelineItem {
val actor: GitLabUserDTO
}
class StateEvent(
val event: GitLabResourceStateEventDTO
) : Immutable {
override val id: String = "StateEvent:" + event.id.toString()
override val actor: GitLabUserDTO = event.user
override val date: Date = event.createdAt
}
class LabelEvent(
val event: GitLabResourceLabelEventDTO
) : Immutable {
override val id: String = "LabelEvent:" + event.id.toString()
override val actor: GitLabUserDTO = event.user
override val date: Date = event.createdAt
}
class MilestoneEvent(
val event: GitLabResourceMilestoneEventDTO
) : Immutable {
override val id: String = "MilestoneEvent:" + event.id.toString()
override val actor: GitLabUserDTO = event.user
override val date: Date = event.createdAt
}
class SystemDiscussion(
discussion: GitLabDiscussionDTO
) : Immutable {
private val firstNote = discussion.notes.first()
override val id: String = discussion.id
override val actor: GitLabUserDTO = firstNote.author
override val date: Date = discussion.createdAt
val content: String = firstNote.body
}
class UserDiscussion(
val discussion: GitLabDiscussion
) : GitLabMergeRequestTimelineItem {
override val id: String = discussion.id
override val date: Date = discussion.createdAt
}
}

View File

@@ -2,53 +2,15 @@
package org.jetbrains.plugins.gitlab.mergerequest.ui.timeline
import kotlinx.coroutines.CoroutineScope
import org.jetbrains.plugins.gitlab.api.dto.*
import org.jetbrains.plugins.gitlab.mergerequest.data.GitLabDiscussion
import java.util.*
sealed interface GitLabMergeRequestTimelineItemViewModel {
val id: String
val date: Date
sealed interface Immutable : GitLabMergeRequestTimelineItemViewModel {
val actor: GitLabUserDTO
}
class StateEvent(
val event: GitLabResourceStateEventDTO
) : Immutable {
override val id: String = "StateEvent:" + event.id.toString()
override val actor: GitLabUserDTO = event.user
override val date: Date = event.createdAt
}
class LabelEvent(
val event: GitLabResourceLabelEventDTO
) : Immutable {
override val id: String = "LabelEvent:" + event.id.toString()
override val actor: GitLabUserDTO = event.user
override val date: Date = event.createdAt
}
class MilestoneEvent(
val event: GitLabResourceMilestoneEventDTO
) : Immutable {
override val id: String = "MilestoneEvent:" + event.id.toString()
override val actor: GitLabUserDTO = event.user
override val date: Date = event.createdAt
}
class SystemDiscussion(
discussion: GitLabDiscussionDTO
) : Immutable {
private val firstNote = discussion.notes.first()
override val id: String = "SystemDiscussion:" + discussion.id
override val actor: GitLabUserDTO = firstNote.author
override val date: Date = discussion.createdAt
val content: String = firstNote.body
class Immutable(
val item: GitLabMergeRequestTimelineItem.Immutable
) : GitLabMergeRequestTimelineItemViewModel {
override val id: String = item.id
}
class Discussion(
@@ -57,7 +19,6 @@ sealed interface GitLabMergeRequestTimelineItemViewModel {
) : GitLabMergeRequestTimelineItemViewModel,
GitLabMergeRequestTimelineDiscussionViewModel
by GitLabMergeRequestTimelineDiscussionViewModelImpl(parentCs, discussion) {
override val id: String = "Discussion:" + discussion.id
override val date: Date = discussion.createdAt
override val id: String = discussion.id
}
}

View File

@@ -1,7 +1,9 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.plugins.gitlab.mergerequest.ui.timeline
import com.intellij.collaboration.async.mapScoped
import com.intellij.collaboration.async.mapCaching
import com.intellij.collaboration.async.modelFlow
import com.intellij.openapi.diagnostic.logger
import com.intellij.util.childScope
import com.intellij.util.concurrency.annotations.RequiresEdt
import kotlinx.coroutines.*
@@ -13,21 +15,22 @@ import org.jetbrains.plugins.gitlab.mergerequest.api.request.loadMergeRequestSta
import org.jetbrains.plugins.gitlab.mergerequest.data.GitLabMergeRequestDiscussionsModelImpl
import org.jetbrains.plugins.gitlab.mergerequest.data.GitLabMergeRequestId
import org.jetbrains.plugins.gitlab.mergerequest.ui.timeline.GitLabMergeRequestTimelineViewModel.LoadingState
import java.util.*
import java.util.concurrent.ConcurrentLinkedQueue
interface GitLabMergeRequestTimelineViewModel {
val timelineLoadingFlow: Flow<LoadingState?>
fun startLoading()
fun reset()
fun requestLoad()
sealed interface LoadingState {
object Loading : LoadingState
class Error(val exception: Throwable) : LoadingState
class Result(val items: List<GitLabMergeRequestTimelineItemViewModel>) : LoadingState
class Result(val items: Flow<List<GitLabMergeRequestTimelineItemViewModel>>) : LoadingState
}
}
private val LOG = logger<GitLabMergeRequestTimelineViewModel>()
class LoadAllGitLabMergeRequestTimelineViewModel(
parentCs: CoroutineScope,
private val connection: GitLabProjectConnection,
@@ -35,100 +38,87 @@ class LoadAllGitLabMergeRequestTimelineViewModel(
) : GitLabMergeRequestTimelineViewModel {
private val cs = parentCs.childScope(Dispatchers.Default)
private var loadingRequestState = MutableStateFlow<Flow<List<GitLabMergeRequestTimelineItemViewModel>>?>(null)
private val timelineLoadingRequests = MutableSharedFlow<Unit>(1)
private val discussionsDataProvider = GitLabMergeRequestDiscussionsModelImpl(cs, connection, mr)
@OptIn(ExperimentalCoroutinesApi::class)
override val timelineLoadingFlow: Flow<LoadingState?> =
loadingRequestState.transformLatest { itemsFlow ->
if (itemsFlow == null) {
emit(null)
return@transformLatest
}
override val timelineLoadingFlow: Flow<LoadingState> =
timelineLoadingRequests.transformLatest {
emit(LoadingState.Loading)
// so it's not a supervisor
coroutineScope {
itemsFlow.catch {
emit(LoadingState.Error(it))
}.collectLatest {
emit(LoadingState.Result(it))
val result = try {
LoadingState.Result(createItemsFlow(this).mapToVms(this).stateIn(this))
}
catch (ce: CancellationException) {
throw ce
}
catch (e: Exception) {
LoadingState.Error(e)
}
emit(result)
awaitCancellation()
}
}
}.modelFlow(cs, LOG)
@RequiresEdt
override fun startLoading() {
loadingRequestState.update {
it ?: createItemsFlow()
override fun requestLoad() {
cs.launch {
timelineLoadingRequests.emit(Unit)
}
}
private fun createItemsFlow(): Flow<List<GitLabMergeRequestTimelineItemViewModel>> {
/**
* Load all simple events and discussions and subscribe to user discussions changes
*/
private suspend fun createItemsFlow(cs: CoroutineScope): Flow<List<GitLabMergeRequestTimelineItem>> {
val api = connection.apiClient
val project = connection.repo.repository
val discussionsFlow = discussionsDataProvider.userDiscussions.mapScoped { discussions ->
val discussionsScope = this
discussions.map {
GitLabMergeRequestTimelineItemViewModel.Discussion(discussionsScope, it)
val simpleEventsRequest = cs.async(Dispatchers.IO) {
val vms = ConcurrentLinkedQueue<GitLabMergeRequestTimelineItem>()
launch {
discussionsDataProvider.systemDiscussions.first()
.map { GitLabMergeRequestTimelineItem.SystemDiscussion(it) }
.also { vms.addAll(it) }
}
launch {
api.loadMergeRequestStateEvents(project, mr).body()
.map { GitLabMergeRequestTimelineItem.StateEvent(it) }
.also { vms.addAll(it) }
}
launch {
api.loadMergeRequestLabelEvents(project, mr).body()
.map { GitLabMergeRequestTimelineItem.LabelEvent(it) }
.also { vms.addAll(it) }
}
launch {
api.loadMergeRequestMilestoneEvents(project, mr).body()
.map { GitLabMergeRequestTimelineItem.MilestoneEvent(it) }
.also { vms.addAll(it) }
}
vms
}
val simpleEventsFlow: Flow<List<GitLabMergeRequestTimelineItemViewModel.Immutable>> =
channelFlow {
launch {
discussionsDataProvider.systemDiscussions.collect { discussions ->
send(discussions.map { GitLabMergeRequestTimelineItemViewModel.SystemDiscussion(it) })
}
}
launch {
api.loadMergeRequestStateEvents(project, mr).body().let { events ->
events.map { GitLabMergeRequestTimelineItemViewModel.StateEvent(it) }
}.also {
send(it)
}
}
launch {
api.loadMergeRequestStateEvents(project, mr).body().let { events ->
events.map { GitLabMergeRequestTimelineItemViewModel.StateEvent(it) }
}.also {
send(it)
}
}
launch {
api.loadMergeRequestLabelEvents(project, mr).body().let { events ->
events.map { GitLabMergeRequestTimelineItemViewModel.LabelEvent(it) }
}.also {
send(it)
}
}
launch {
api.loadMergeRequestMilestoneEvents(project, mr).body().let { events ->
events.map { GitLabMergeRequestTimelineItemViewModel.MilestoneEvent(it) }
}.also {
send(it)
}
}
}.flowOn(Dispatchers.IO)
return combine(discussionsFlow, simpleEventsFlow) { discussions, simpleEvents ->
TreeSet(Comparator.comparing(GitLabMergeRequestTimelineItemViewModel::date)).apply {
addAll(simpleEvents)
addAll(discussions)
}.toList()
return discussionsDataProvider.userDiscussions.map { discussions ->
(simpleEventsRequest.await() + discussions.map(GitLabMergeRequestTimelineItem::UserDiscussion)).sortedBy { it.date }
}
}
@RequiresEdt
override fun reset() {
loadingRequestState.value = null
}
private suspend fun Flow<List<GitLabMergeRequestTimelineItem>>.mapToVms(cs: CoroutineScope) =
mapCaching(
GitLabMergeRequestTimelineItem::id,
{ createVm(cs, it) },
{ if (this is GitLabMergeRequestTimelineItemViewModel.Discussion) destroy() }
)
private fun createVm(cs: CoroutineScope, item: GitLabMergeRequestTimelineItem): GitLabMergeRequestTimelineItemViewModel =
when (item) {
is GitLabMergeRequestTimelineItem.Immutable -> GitLabMergeRequestTimelineItemViewModel.Immutable(item)
is GitLabMergeRequestTimelineItem.UserDiscussion -> GitLabMergeRequestTimelineItemViewModel.Discussion(cs, item.discussion)
}
}

View File

@@ -40,7 +40,7 @@ class GitLabNoteEditingViewModelImpl(parentCs: CoroutineScope,
val newText = text.first()
_state.value = GitLabNoteEditingViewModel.SubmissionState.Loading
try {
note.updateBody(newText)
note.setBody(newText)
_state.value = null
onDone()
}
@@ -54,6 +54,11 @@ class GitLabNoteEditingViewModelImpl(parentCs: CoroutineScope,
}
override suspend fun destroy() {
cs.coroutineContext[Job]!!.cancelAndJoin()
try {
cs.coroutineContext[Job]!!.cancelAndJoin()
}
catch (e: CancellationException) {
// ignore, cuz we don't want to cancel the invoker
}
}
}

View File

@@ -1,13 +1,12 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.plugins.gitlab.ui.comment
import com.intellij.collaboration.async.modelFlow
import com.intellij.openapi.diagnostic.logger
import com.intellij.util.childScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import org.jetbrains.annotations.Nls
import org.jetbrains.plugins.gitlab.api.dto.GitLabUserDTO
import org.jetbrains.plugins.gitlab.mergerequest.data.GitLabNote
@@ -15,6 +14,7 @@ import org.jetbrains.plugins.gitlab.ui.GitLabUIUtil
import java.util.*
interface GitLabNoteViewModel {
val id: String
val author: GitLabUserDTO
val createdAt: Date
@@ -23,6 +23,8 @@ interface GitLabNoteViewModel {
val htmlBody: Flow<@Nls String>
}
private val LOG = logger<GitLabNoteViewModel>()
class GitLabNoteViewModelImpl(
parentCs: CoroutineScope,
note: GitLabNote
@@ -30,6 +32,7 @@ class GitLabNoteViewModelImpl(
private val cs = parentCs.childScope(Dispatchers.Default)
override val id: String = note.id
override val author: GitLabUserDTO = note.author
override val createdAt: Date = note.createdAt
@@ -37,6 +40,14 @@ class GitLabNoteViewModelImpl(
if (note.canAdmin) GitLabNoteAdminActionsViewModelImpl(cs, note) else null
private val body: Flow<String> = note.body
override val htmlBody: Flow<String> = body.map { GitLabUIUtil.convertToHtml(it) }
.shareIn(cs, SharingStarted.Lazily, 1)
override val htmlBody: Flow<String> = body.map { GitLabUIUtil.convertToHtml(it) }.modelFlow(cs, LOG)
suspend fun destroy() {
try {
cs.coroutineContext[Job]!!.cancelAndJoin()
}
catch (e: CancellationException) {
// ignore, cuz we don't want to cancel the invoker
}
}
}