From df744a30ebb99cb73d88eb4832f8cd40a0f7d6d2 Mon Sep 17 00:00:00 2001 From: Ivan Semenov Date: Fri, 20 Jan 2023 16:59:59 +0100 Subject: [PATCH] [gitlab] avoid timeline relayout on model changes GitOrigin-RevId: d18d5df2997b8d0298fa44dcd9a90d75e57e5ca8 --- .../collaboration/async/CoroutineUtil.kt | 51 +++++- .../ui/ComponentListPanelFactory.kt | 78 ++++++++++ .../mergerequest/data/GitLabDiscussion.kt | 100 ++++++------ .../data/GitLabDiscussionEvent.kt | 2 +- .../GitLabMergeRequestDiscussionsModel.kt | 60 +++---- .../gitlab/mergerequest/data/GitLabNote.kt | 52 ++++--- .../mergerequest/data/GitLabNoteEvent.kt | 5 +- ...LabMergeRequestTimelineComponentFactory.kt | 39 ++--- ...questTimelineDiscussionComponentFactory.kt | 36 ++--- ...MergeRequestTimelineDiscussionViewModel.kt | 50 ++++-- .../GitLabMergeRequestTimelineItem.kt | 60 +++++++ ...GitLabMergeRequestTimelineItemViewModel.kt | 49 +----- .../GitLabMergeRequestTimelineViewModel.kt | 146 ++++++++---------- .../ui/comment/GitLabNoteEditingViewModel.kt | 9 +- .../gitlab/ui/comment/GitLabNoteViewModel.kt | 23 ++- 15 files changed, 470 insertions(+), 290 deletions(-) create mode 100644 plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineItem.kt diff --git a/platform/collaboration-tools/src/com/intellij/collaboration/async/CoroutineUtil.kt b/platform/collaboration-tools/src/com/intellij/collaboration/async/CoroutineUtil.kt index ba643cb7a63a..d29c85e038e1 100644 --- a/platform/collaboration-tools/src/com/intellij/collaboration/async/CoroutineUtil.kt +++ b/platform/collaboration-tools/src/com/intellij/collaboration/async/CoroutineUtil.kt @@ -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 Flow.collectWithPrevious(initial: T, collector: suspend (prev collector(prev, it) prev = it } -} \ No newline at end of file +} + +/** + * Lazy shared flow that logs all exceptions as errors and never throws (beside cancellation) + */ +fun Flow.modelFlow(cs: CoroutineScope, log: Logger): SharedFlow = + catch { log.error(it) }.shareIn(cs, SharingStarted.Lazily, 1) + +fun Flow>.mapCaching(sourceIdentifier: (T) -> ID, + mapper: suspend (T) -> R, + destroy: suspend R.() -> Unit, + update: (suspend R.(T) -> Unit)? = null): Flow> { + var initial = true + val result = LinkedHashMap() + 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()) + } + } +} diff --git a/platform/collaboration-tools/src/com/intellij/collaboration/ui/ComponentListPanelFactory.kt b/platform/collaboration-tools/src/com/intellij/collaboration/ui/ComponentListPanelFactory.kt index d26e865dc19f..f4eef8e638c8 100644 --- a/platform/collaboration-tools/src/com/intellij/collaboration/ui/ComponentListPanelFactory.kt +++ b/platform/collaboration-tools/src/com/intellij/collaboration/ui/ComponentListPanelFactory.kt @@ -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 createVertical(parentCs: CoroutineScope, + items: Flow>, + itemKeyExtractor: ((T) -> Any), + gap: Int, + componentFactory: suspend (CoroutineScope, T) -> JComponent): JPanel { + val cs = parentCs.childScope(Dispatchers.Main) + val panel = VerticalListPanel(gap) + val keyList = LinkedList() + + 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("ComponentListPanelFactory.Component.Scope") } \ No newline at end of file diff --git a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabDiscussion.kt b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabDiscussion.kt index 470144fa5f0b..ff0659600cca 100644 --- a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabDiscussion.kt +++ b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabDiscussion.kt @@ -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() + 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().info(e.localizedMessage) - }) + private val cs = parentCs.childScope(CoroutineExceptionHandler { _, e -> LOG.warn(e) }) private val operationsGuard = Mutex() - private val events = MutableSharedFlow() - private val loadedNotes = MutableSharedFlow>(1).apply { - tryEmit(discussion.notes) - } + private val noteEvents = MutableSharedFlow() + private val loadedNotes = channelFlow { + val notesData = discussionData.notes.toMutableList() - override val notes: Flow> = loadedNotes.transformLatest, List> { loadedNotes -> - coroutineScope { - val notesCs = this - val notes = Collections.synchronizedMap(LinkedHashMap()) - 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> = + 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 = firstNote.map { it.resolved } + override val canResolve: Boolean = discussionData.notes.first().let { it.resolvable && it.userPermissions.resolveNote } + override val resolved: Flow = + 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) { - 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)" } \ No newline at end of file diff --git a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabDiscussionEvent.kt b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabDiscussionEvent.kt index fa0f732c1e90..a592c2f26a9c 100644 --- a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabDiscussionEvent.kt +++ b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabDiscussionEvent.kt @@ -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 } \ No newline at end of file diff --git a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabMergeRequestDiscussionsModel.kt b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabMergeRequestDiscussionsModel.kt index 832a450c39bf..85fe8ec370af 100644 --- a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabMergeRequestDiscussionsModel.kt +++ b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabMergeRequestDiscussionsModel.kt @@ -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> val systemDiscussions: Flow> } -@OptIn(ExperimentalCoroutinesApi::class) +private val LOG = logger() + class GitLabMergeRequestDiscussionsModelImpl( parentCs: CoroutineScope, private val connection: GitLabProjectConnection, private val mr: GitLabMergeRequestId ) : GitLabMergeRequestDiscussionsModel { - private val cs = parentCs.childScope(Dispatchers.Default) - private val events = MutableSharedFlow(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> = nonEmptyDiscussionsData.transformLatest { loadedDiscussions -> - coroutineScope { - val discussionsCs = this - val discussions = ConcurrentHashMap() - 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() + private val nonEmptyDiscussionsData: Flow> = + 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> = + 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> = - 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 = ApiPageUtil.createGQLPagesFlow { @@ -64,4 +64,6 @@ class GitLabMergeRequestDiscussionsModelImpl( }.map { discussions -> discussions.filter { it.notes.isNotEmpty() } }.foldToList() + + private fun Flow>.mapFiltered(predicate: (T) -> Boolean): Flow> = map { it.filter(predicate) } } \ No newline at end of file diff --git a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabNote.kt b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabNote.kt index 00f3228e4ebd..578cd3d4a84c 100644 --- a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabNote.kt +++ b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabNote.kt @@ -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 - suspend fun updateBody(newText: String) + suspend fun setBody(newText: String) suspend fun delete() + + suspend fun update(item: GitLabNoteDTO) } +private val LOG = logger() + 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().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 = _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})" } diff --git a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabNoteEvent.kt b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabNoteEvent.kt index ce76e3228f39..edf0c1d626ce 100644 --- a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabNoteEvent.kt +++ b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabNoteEvent.kt @@ -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) : GitLabNoteEvent + class Deleted(val noteId: String) : GitLabNoteEvent } \ No newline at end of file diff --git a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineComponentFactory.kt b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineComponentFactory.kt index 5cb2b3e1d6b1..bb1740834041 100644 --- a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineComponentFactory.kt +++ b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineComponentFactory.kt @@ -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") diff --git a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineDiscussionComponentFactory.kt b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineDiscussionComponentFactory.kt index a22a3a55ffcb..b3ae3a8df6a1 100644 --- a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineDiscussionComponentFactory.kt +++ b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineDiscussionComponentFactory.kt @@ -40,12 +40,12 @@ object GitLabMergeRequestTimelineDiscussionComponentFactory { fun create(project: Project, cs: CoroutineScope, avatarIconsProvider: IconsProvider, - 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, - item: GitLabMergeRequestTimelineItemViewModel.Discussion): JComponent { + item: GitLabMergeRequestTimelineDiscussionViewModel): JComponent { val authorsLabel = JLabel().apply { bindVisibility(cs, item.replies.map { it.isNotEmpty() }) diff --git a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineDiscussionViewModel.kt b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineDiscussionViewModel.kt index 0a0c152836ba..d0b0bd5a5fa3 100644 --- a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineDiscussionViewModel.kt +++ b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineDiscussionViewModel.kt @@ -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 val mainNote: Flow @@ -24,28 +29,39 @@ interface GitLabMergeRequestTimelineDiscussionViewModel { val resolvedVm: GitLabMergeRequestDiscussionResolveViewModel? fun setRepliesFolded(folded: Boolean) + + suspend fun destroy() } +private val LOG = logger() + 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 = discussion.notes.mapScoped { - createNoteVm(this, it.first()) - }.share() + override val mainNote: Flow = 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 = mainNote.map { it.author } private val _repliesFolded = MutableStateFlow(true) override val repliesFolded: Flow = _repliesFolded.asStateFlow() - override val replies: Flow> = - discussion.notes.mapScoped { notesList -> - notesList.asSequence().drop(1).map { createNoteVm(this, it) }.toList() - }.share() - - override val author: Flow = mainNote.map { it.author } + override val replies: Flow> = 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 Flow.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 + } + } } \ No newline at end of file diff --git a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineItem.kt b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineItem.kt new file mode 100644 index 000000000000..25dba03fa04b --- /dev/null +++ b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineItem.kt @@ -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 + } +} \ No newline at end of file diff --git a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineItemViewModel.kt b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineItemViewModel.kt index 20dc1e07c08c..ad3b1bc18a87 100644 --- a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineItemViewModel.kt +++ b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineItemViewModel.kt @@ -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 } } \ No newline at end of file diff --git a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineViewModel.kt b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineViewModel.kt index 3a4ca49e3a85..f2b672db04e0 100644 --- a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineViewModel.kt +++ b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineViewModel.kt @@ -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 - fun startLoading() - fun reset() + fun requestLoad() sealed interface LoadingState { object Loading : LoadingState class Error(val exception: Throwable) : LoadingState - class Result(val items: List) : LoadingState + class Result(val items: Flow>) : LoadingState } } +private val LOG = logger() + 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>?>(null) + private val timelineLoadingRequests = MutableSharedFlow(1) private val discussionsDataProvider = GitLabMergeRequestDiscussionsModelImpl(cs, connection, mr) @OptIn(ExperimentalCoroutinesApi::class) - override val timelineLoadingFlow: Flow = - loadingRequestState.transformLatest { itemsFlow -> - if (itemsFlow == null) { - emit(null) - return@transformLatest - } - + override val timelineLoadingFlow: Flow = + 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> { + /** + * Load all simple events and discussions and subscribe to user discussions changes + */ + private suspend fun createItemsFlow(cs: CoroutineScope): Flow> { 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() + 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> = - 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>.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) + } } \ No newline at end of file diff --git a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabNoteEditingViewModel.kt b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabNoteEditingViewModel.kt index 1d4ab35f9c48..499bda07ff71 100644 --- a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabNoteEditingViewModel.kt +++ b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabNoteEditingViewModel.kt @@ -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 + } } } \ No newline at end of file diff --git a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabNoteViewModel.kt b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabNoteViewModel.kt index 7395a90ff412..16fca4240642 100644 --- a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabNoteViewModel.kt +++ b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabNoteViewModel.kt @@ -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() + 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 = note.body - override val htmlBody: Flow = body.map { GitLabUIUtil.convertToHtml(it) } - .shareIn(cs, SharingStarted.Lazily, 1) + override val htmlBody: Flow = 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 + } + } } \ No newline at end of file