diff --git a/platform/collaboration-tools/api-dump-experimental.txt b/platform/collaboration-tools/api-dump-experimental.txt index c406f26fcf46..13e633ae93a9 100644 --- a/platform/collaboration-tools/api-dump-experimental.txt +++ b/platform/collaboration-tools/api-dump-experimental.txt @@ -549,6 +549,7 @@ f:com.intellij.collaboration.api.json.JsonHttpApiHelperKt - getProject():com.intellij.openapi.project.Project - f:getState():kotlinx.coroutines.flow.StateFlow - f:getText():kotlinx.coroutines.flow.MutableStateFlow +- pf:launchTask(kotlin.jvm.functions.Function1):V - f:requestFocus():V - pf:submit(kotlin.jvm.functions.Function2):V *:com.intellij.collaboration.ui.codereview.comment.CodeReviewTextEditingViewModel diff --git a/platform/collaboration-tools/src/com/intellij/collaboration/ui/codereview/comment/CodeReviewSubmittableTextViewModel.kt b/platform/collaboration-tools/src/com/intellij/collaboration/ui/codereview/comment/CodeReviewSubmittableTextViewModel.kt index 5f49b42fa258..ddf1dec2c1a3 100644 --- a/platform/collaboration-tools/src/com/intellij/collaboration/ui/codereview/comment/CodeReviewSubmittableTextViewModel.kt +++ b/platform/collaboration-tools/src/com/intellij/collaboration/ui/codereview/comment/CodeReviewSubmittableTextViewModel.kt @@ -53,6 +53,17 @@ abstract class CodeReviewSubmittableTextViewModelBase( } } + protected fun launchTask(task: suspend () -> Unit) { + taskLauncher.launch { + _state.value = ComputedResult.loading() + try { + task() + } finally { + _state.value = null + } + } + } + protected fun submit(submitter: suspend (String) -> Unit) { taskLauncher.launch { val newText = text.first() diff --git a/plugins/gitlab/gitlab-core/resources/messages/GitLabBundle.properties b/plugins/gitlab/gitlab-core/resources/messages/GitLabBundle.properties index 306b5aea5911..fd9a331a001a 100644 --- a/plugins/gitlab/gitlab-core/resources/messages/GitLabBundle.properties +++ b/plugins/gitlab/gitlab-core/resources/messages/GitLabBundle.properties @@ -215,6 +215,15 @@ snippet.create.error.some-empty-contents.tooltip=Empty files are excluded from s action.GitLab.Review.Upload.File.text=Upload File\u2026 action.GitLab.Review.Upload.File.description=Upload a file to GitLab +## Progress and notification after clicking 'Ok' in a dialog about uploading a file +upload.file.action.progress=Uploading file\u2026 +upload.file.action.error.title=Uploading file failed + +## Upload file dialog messages +upload.file.dialog.title=Upload File +upload.file.dialog.file.not.found=File not found +upload.file.dialog.not.readable=File is not readable + ### Share Project in dialog action.GitLab.Share.text=Share Project on GitLab # suppress inspection "UnusedProperty" (should be used by VCS Toolbar in New UI) diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/api/dto/GitLabUploadRestDTO.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/api/dto/GitLabUploadRestDTO.kt new file mode 100644 index 000000000000..3808222380e3 --- /dev/null +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/api/dto/GitLabUploadRestDTO.kt @@ -0,0 +1,19 @@ +// 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.gitlab.api.dto + +import com.intellij.openapi.util.NlsSafe +import org.jetbrains.plugins.gitlab.api.SinceGitLab + +/** + * Uploaded file data. + * + * @see Markdown uploads API + */ +@SinceGitLab("15.10") +class GitLabUploadRestDTO( + @SinceGitLab("17.3") val id: String, + val alt: @NlsSafe String, + val url: @NlsSafe String, + @SinceGitLab("17.1") val fullPath: @NlsSafe String, + val markdown: @NlsSafe String, +) \ No newline at end of file diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabProject.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabProject.kt index 177de5eec107..c248ed8a7435 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabProject.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/data/GitLabProject.kt @@ -20,8 +20,11 @@ import org.jetbrains.plugins.gitlab.api.request.* import org.jetbrains.plugins.gitlab.mergerequest.api.dto.GitLabMergeRequestDTO import org.jetbrains.plugins.gitlab.mergerequest.api.request.loadMergeRequest import org.jetbrains.plugins.gitlab.mergerequest.api.request.mergeRequestSetReviewers +import org.jetbrains.plugins.gitlab.upload.markdownUploadFile import org.jetbrains.plugins.gitlab.util.GitLabProjectMapping import org.jetbrains.plugins.gitlab.util.GitLabRegistry +import java.nio.file.Files +import java.nio.file.Path private val LOG = logger() @@ -52,6 +55,9 @@ interface GitLabProject { suspend fun adjustReviewers(mrIid: String, reviewers: List): GitLabMergeRequestDTO fun reloadData() + + suspend fun uploadFile(path: Path): String + fun canUploadFile(): Boolean } @CodeReviewDomainEntity @@ -153,6 +159,19 @@ class GitLabLazyProject( _dataReloadSignal.tryEmit(Unit) } + override suspend fun uploadFile(path: Path): String { + val uploadRestDTO = withContext(cs.coroutineContext + Dispatchers.IO) { + Files.newInputStream(path).use { + api.rest.markdownUploadFile(projectCoordinates, path).body() + } + } + return uploadRestDTO.markdown + } + + override fun canUploadFile(): Boolean { + return glMetadata != null && glMetadata.version >= GitLabVersion(15, 10) + } + private suspend fun getAllowsMultipleAssigneesPropertyFromNamespacePlan() = try { api.rest.getProjectNamespace(projectMapping.repository.projectPath.owner).body()?.plan?.let { it != GitLabPlan.FREE diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/review/GitLabMergeRequestDiscussionsViewModels.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/review/GitLabMergeRequestDiscussionsViewModels.kt index 5e209989d8ce..235884ff2328 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/review/GitLabMergeRequestDiscussionsViewModels.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/review/GitLabMergeRequestDiscussionsViewModels.kt @@ -72,7 +72,7 @@ fun GitLabMergeRequestDiscussionsViewModels.NewDiscussionPosition.mapToLocation( internal class GitLabMergeRequestDiscussionsViewModelsImpl( private val project: Project, parentCs: CoroutineScope, - projectData: GitLabProject, + private val projectData: GitLabProject, private val currentUser: GitLabUserDTO, private val mergeRequest: GitLabMergeRequest, htmlConverter: GitLabMarkdownToHtmlConverter, @@ -93,7 +93,7 @@ internal class GitLabMergeRequestDiscussionsViewModelsImpl( .transformConsecutiveSuccesses { mapFiltered { it.discussionId == null } .mapStatefulToStateful { - GitLabMergeRequestStandaloneDraftNoteViewModelBase(project, this, it, mergeRequest, htmlConverter) + GitLabMergeRequestStandaloneDraftNoteViewModelBase(project, this, it, mergeRequest, projectData, htmlConverter) } } .stateInNow(cs, ComputedResult.loading()) @@ -149,7 +149,7 @@ internal class GitLabMergeRequestDiscussionsViewModelsImpl( override fun requestNewDiscussion(position: GitLabMergeRequestDiscussionsViewModels.NewDiscussionPosition, focus: Boolean) { _newDiscussions.updateAndGet { currentNewDiscussions -> if (!currentNewDiscussions.containsKey(position) && mergeRequest.canAddNotes) { - val vm = GitLabNoteEditingViewModel.forNewDiffNote(cs, project, mergeRequest, currentUser, position.position).apply { + val vm = GitLabNoteEditingViewModel.forNewDiffNote(cs, project, projectData, mergeRequest, currentUser, position.position).apply { onDoneIn(cs) { cancelNewDiscussion(position) } diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineDiscussionViewModel.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineDiscussionViewModel.kt index 2d35b7a16569..0d0bb1497c84 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineDiscussionViewModel.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineDiscussionViewModel.kt @@ -100,7 +100,7 @@ class GitLabMergeRequestTimelineDiscussionViewModelImpl( override val canCreateReplies: StateFlow = discussion.canAddNotes.stateIn(cs, SharingStarted.Eagerly, false) override val replyVm: StateFlow = discussion.canAddNotes.mapScoped { canAddNotes -> - if (canAddNotes) GitLabNoteEditingViewModel.forReplyNote(this, project, discussion, currentUser) else null + if (canAddNotes) GitLabNoteEditingViewModel.forReplyNote(this, project, projectData, discussion, currentUser) else null }.stateIn(cs, SharingStarted.Eagerly, null) override val diffVm: Flow = diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineItemViewModel.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineItemViewModel.kt index 7cfac6dae275..b1e64131463d 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineItemViewModel.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineItemViewModel.kt @@ -107,6 +107,7 @@ sealed interface GitLabMergeRequestTimelineItemViewModel { project: Project, parentCs: CoroutineScope, mr: GitLabMergeRequest, + projectData: GitLabProject, note: GitLabMergeRequestNote, htmlConverter: GitLabMarkdownToHtmlConverter ) : GitLabMergeRequestTimelineItemViewModel, GitLabNoteViewModel { @@ -119,7 +120,7 @@ sealed interface GitLabMergeRequestTimelineItemViewModel { override val serverUrl: URL = mr.glProject.serverPath.toURL() override val actionsVm: GitLabNoteAdminActionsViewModel? = - if (note is MutableGitLabNote && note.canAdmin) GitLabNoteAdminActionsViewModelImpl(cs, project, note) else null + if (note is MutableGitLabNote && note.canAdmin) GitLabNoteAdminActionsViewModelImpl(cs, project, projectData, note) else null override val reactionsVm: GitLabReactionsViewModel? = null override val body: StateFlow = note.body diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineViewModel.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineViewModel.kt index fe1ad04e0fcf..cd841b7b07cd 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineViewModel.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/timeline/GitLabMergeRequestTimelineViewModel.kt @@ -74,7 +74,7 @@ internal class LoadAllGitLabMergeRequestTimelineViewModel( override val newNoteVm: NewGitLabNoteViewModel? = if (mergeRequest.canAddNotes) { - GitLabNoteEditingViewModel.forNewNote(cs, project, mergeRequest, currentUser).apply { + GitLabNoteEditingViewModel.forNewNote(cs, project, projectData, mergeRequest, currentUser).apply { onDoneIn(cs) { text.value = "" } @@ -153,7 +153,7 @@ internal class LoadAllGitLabMergeRequestTimelineViewModel( handleDiffRequests(it.diffVm, _diffRequests::emit) } is GitLabMergeRequestTimelineItem.DraftNote -> - GitLabMergeRequestTimelineItemViewModel.DraftNote(project, cs, mergeRequest, item.note, htmlConverter).also { + GitLabMergeRequestTimelineItemViewModel.DraftNote(project, cs, mergeRequest, projectData, item.note, htmlConverter).also { handleDiffRequests(it.diffVm, _diffRequests::emit) } } diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/notification/GitLabNotificationIdsHolder.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/notification/GitLabNotificationIdsHolder.kt index 27ea9cad0b7a..faa571454264 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/notification/GitLabNotificationIdsHolder.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/notification/GitLabNotificationIdsHolder.kt @@ -3,8 +3,13 @@ package org.jetbrains.plugins.gitlab.notification import com.intellij.notification.impl.NotificationIdsHolder + +internal object GitLabNotificationIds { + const val GL_NOTIFICATION_UPLOAD_FILE_ERROR = "gitlab.upload.file.action.error" +} + class GitLabNotificationIdsHolder : NotificationIdsHolder { - override fun getNotificationIds(): List { - return listOf() - } + override fun getNotificationIds(): List = listOf( + GitLabNotificationIds.GL_NOTIFICATION_UPLOAD_FILE_ERROR, + ) } \ No newline at end of file diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabCodeReviewCommentTextFieldFactory.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabCodeReviewCommentTextFieldFactory.kt index 2205cffd16b6..6b32fe430504 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabCodeReviewCommentTextFieldFactory.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabCodeReviewCommentTextFieldFactory.kt @@ -3,8 +3,6 @@ package org.jetbrains.plugins.gitlab.ui.comment import com.intellij.collaboration.ui.EditableComponentFactory import com.intellij.collaboration.ui.codereview.comment.CodeReviewCommentTextFieldFactory -import com.intellij.collaboration.ui.codereview.comment.CodeReviewSubmittableTextViewModel -import com.intellij.collaboration.ui.codereview.comment.CodeReviewTextEditingViewModel import com.intellij.collaboration.ui.codereview.comment.CommentInputActionsComponentFactory import com.intellij.collaboration.ui.codereview.comment.createEditActionsConfig import com.intellij.collaboration.ui.codereview.timeline.comment.CommentTextFieldFactory @@ -30,15 +28,17 @@ object GitLabCodeReviewCommentTextFieldFactory { icon: CommentTextFieldFactory.IconConfig? = null, ): JComponent { + val canUploadFile = Registry.`is`("gitlab.merge.requests.file.upload.enabled") && vm.canUploadFile() + val uploadFileAction = object : AnAction(GitLabBundle.message("action.GitLab.Review.Upload.File.text"), GitLabBundle.message("action.GitLab.Review.Upload.File.description"), AllIcons.Actions.Upload) { override fun actionPerformed(e: AnActionEvent) { - TODO("Not yet implemented") + vm.uploadFile(null) } override fun update(e: AnActionEvent) { - e.presentation.isEnabledAndVisible = Registry.`is`("gitlab.merge.requests.file.upload.enabled") + e.presentation.isEnabledAndVisible = canUploadFile } override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabDiscussionReplyViewModel.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabDiscussionReplyViewModel.kt index 000d6932143f..b57c84c2bcde 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabDiscussionReplyViewModel.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabDiscussionReplyViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import org.jetbrains.plugins.gitlab.api.dto.GitLabUserDTO import org.jetbrains.plugins.gitlab.mergerequest.data.GitLabDiscussion +import org.jetbrains.plugins.gitlab.mergerequest.data.GitLabProject interface GitLabDiscussionReplyViewModel { @@ -24,6 +25,7 @@ class GitLabDiscussionReplyViewModelImpl( parentCs: CoroutineScope, project: Project, currentUser: GitLabUserDTO, + projectData: GitLabProject, discussion: GitLabDiscussion ) : GitLabDiscussionReplyViewModel { @@ -33,7 +35,7 @@ class GitLabDiscussionReplyViewModelImpl( override val newNoteVm: Flow = isWriting.mapScoped { if (!it) return@mapScoped null val cs = this - GitLabNoteEditingViewModel.forReplyNote(cs, project, discussion, currentUser).apply { + GitLabNoteEditingViewModel.forReplyNote(cs, project, projectData, discussion, currentUser).apply { onDoneIn(cs) { text.value = "" } diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabMergeRequestDiscussionViewModel.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabMergeRequestDiscussionViewModel.kt index 31ef5f311483..dedf4e2badc6 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabMergeRequestDiscussionViewModel.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabMergeRequestDiscussionViewModel.kt @@ -64,7 +64,7 @@ internal class GitLabMergeRequestDiscussionViewModelBase( override val replyVm: StateFlow = discussion.canAddNotes.mapScoped { canAddNotes -> - if (canAddNotes) GitLabDiscussionReplyViewModelImpl(this, project, currentUser, discussion) + if (canAddNotes) GitLabDiscussionReplyViewModelImpl(this, project, currentUser, projectData, discussion) else null }.stateInNow(cs, null) @@ -118,6 +118,7 @@ class GitLabMergeRequestStandaloneDraftNoteViewModelBase internal constructor( parentCs: CoroutineScope, note: GitLabMergeRequestDraftNote, mr: GitLabMergeRequest, + projectData: GitLabProject, htmlConverter: GitLabMarkdownToHtmlConverter, ) : GitLabNoteViewModel { @@ -130,7 +131,7 @@ class GitLabMergeRequestStandaloneDraftNoteViewModelBase internal constructor( override val serverUrl: URL = mr.glProject.serverPath.toURL() override val actionsVm: GitLabNoteAdminActionsViewModel? = - if (note.canAdmin) GitLabNoteAdminActionsViewModelImpl(cs, project, note) else null + if (note.canAdmin) GitLabNoteAdminActionsViewModelImpl(cs, project, projectData, note) else null override val reactionsVm: GitLabReactionsViewModel? = null override val body: StateFlow = note.body diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabNoteAdminActionsViewModel.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabNoteAdminActionsViewModel.kt index 727162e74cea..eda3cba771ef 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabNoteAdminActionsViewModel.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabNoteAdminActionsViewModel.kt @@ -7,6 +7,7 @@ import com.intellij.openapi.project.Project import com.intellij.platform.util.coroutines.childScope import kotlinx.coroutines.* import kotlinx.coroutines.flow.* +import org.jetbrains.plugins.gitlab.mergerequest.data.GitLabProject import org.jetbrains.plugins.gitlab.mergerequest.data.MutableGitLabNote interface GitLabNoteAdminActionsViewModel { @@ -41,7 +42,8 @@ private val LOG = logger() class GitLabNoteAdminActionsViewModelImpl( parentCs: CoroutineScope, private val project: Project, - private val note: MutableGitLabNote + projectData: GitLabProject, + private val note: MutableGitLabNote, ) : GitLabNoteAdminActionsViewModel { private val cs = parentCs.childScope() @@ -53,7 +55,7 @@ class GitLabNoteAdminActionsViewModelImpl( if (editing) { coroutineScope { val cs = this@coroutineScope - val editVm = GitLabNoteEditingViewModel.forExistingNote(cs, project, note) { + val editVm = GitLabNoteEditingViewModel.forExistingNote(cs, project, projectData, note) { stopEditing() } editVm.requestFocus() diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabNoteEditingViewModel.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabNoteEditingViewModel.kt index 1c4da55778cc..cbb63e6f5367 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabNoteEditingViewModel.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabNoteEditingViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jetbrains.annotations.Nls import org.jetbrains.plugins.gitlab.api.dto.GitLabUserDTO @@ -22,12 +23,18 @@ import org.jetbrains.plugins.gitlab.mergerequest.GitLabMergeRequestsPreferences import org.jetbrains.plugins.gitlab.mergerequest.data.GitLabDiscussion import org.jetbrains.plugins.gitlab.mergerequest.data.GitLabMergeRequest import org.jetbrains.plugins.gitlab.mergerequest.data.GitLabMergeRequestNewDiscussionPosition +import org.jetbrains.plugins.gitlab.mergerequest.data.GitLabProject import org.jetbrains.plugins.gitlab.mergerequest.data.MutableGitLabNote +import org.jetbrains.plugins.gitlab.upload.GitLabUploadFileUtil import org.jetbrains.plugins.gitlab.util.GitLabStatistics +import java.nio.file.Path import java.util.* import javax.swing.Action -interface GitLabCodeReviewSubmittableTextViewModel: CodeReviewSubmittableTextViewModel +interface GitLabCodeReviewSubmittableTextViewModel : CodeReviewSubmittableTextViewModel { + fun uploadFile(path: Path?) + fun canUploadFile(): Boolean +} interface GitLabNoteEditingViewModel : GitLabCodeReviewSubmittableTextViewModel { suspend fun destroy() @@ -36,53 +43,69 @@ interface GitLabNoteEditingViewModel : GitLabCodeReviewSubmittableTextViewModel internal fun forExistingNote( parentCs: CoroutineScope, project: Project, + projectData: GitLabProject, note: MutableGitLabNote, onStopEditing: () -> Unit ): GitLabCodeReviewTextEditingViewModel = - GitLabNoteEditingViewModelImpl(project, parentCs, note, onStopEditing) + GitLabNoteEditingViewModelImpl(project, parentCs, projectData, note, onStopEditing) internal fun forNewNote( parentCs: CoroutineScope, project: Project, + projectData: GitLabProject, mergeRequest: GitLabMergeRequest, currentUser: GitLabUserDTO ): NewGitLabNoteViewModel = - NewStandaloneGitLabNoteViewModel(project, parentCs, "", mergeRequest, currentUser) + NewStandaloneGitLabNoteViewModel(project, parentCs, "", projectData, mergeRequest, currentUser) internal fun forNewDiffNote( parentCs: CoroutineScope, project: Project, + projectData: GitLabProject, mergeRequest: GitLabMergeRequest, currentUser: GitLabUserDTO, position: GitLabMergeRequestNewDiscussionPosition ): NewGitLabNoteViewModel = - NewDiffGitLabNoteViewModel(project, parentCs, "", mergeRequest, currentUser, position) + NewDiffGitLabNoteViewModel(project, parentCs, "", projectData, mergeRequest, currentUser, position) internal fun forReplyNote( parentCs: CoroutineScope, project: Project, + projectData: GitLabProject, discussion: GitLabDiscussion, currentUser: GitLabUserDTO ): NewGitLabNoteViewModel = - NewReplyGitLabNoteViewModel(project, parentCs, "", discussion, currentUser) + NewReplyGitLabNoteViewModel(project, parentCs, "", projectData, discussion, currentUser) } } abstract class AbstractGitLabNoteEditingViewModel( override val project: Project, + val projectData: GitLabProject, parentCs: CoroutineScope, initialText: String ) : CodeReviewSubmittableTextViewModelBase(project, parentCs, initialText), GitLabNoteEditingViewModel { override suspend fun destroy() = cs.cancelAndJoinSilently() + + override fun uploadFile(path: Path?) { + launchTask { + GitLabUploadFileUtil.uploadFileAndNotify(project, projectData, path)?.let { fileText -> + text.update { if (it.isBlank()) fileText else it.trimEnd() + "\n\n" + fileText } + } + } + } + + override fun canUploadFile(): Boolean = projectData.canUploadFile() } interface GitLabCodeReviewTextEditingViewModel : GitLabCodeReviewSubmittableTextViewModel, CodeReviewTextEditingViewModel private class GitLabNoteEditingViewModelImpl( project: Project, parentCs: CoroutineScope, + projectData: GitLabProject, private val note: MutableGitLabNote, private val onStopEditing: () -> Unit, -) : AbstractGitLabNoteEditingViewModel(project, parentCs, note.body.value), GitLabCodeReviewTextEditingViewModel { +) : AbstractGitLabNoteEditingViewModel(project, projectData, parentCs, note.body.value), GitLabCodeReviewTextEditingViewModel { override fun save() { submit { note.setBody(it) @@ -111,9 +134,10 @@ interface NewGitLabNoteViewModel : private abstract class NewGitLabNoteViewModelBase( project: Project, parentCs: CoroutineScope, + projectData: GitLabProject, initialText: String, - override val currentUser: GitLabUserDTO -) : AbstractGitLabNoteEditingViewModel(project, parentCs, initialText), NewGitLabNoteViewModel { + override val currentUser: GitLabUserDTO, +) : AbstractGitLabNoteEditingViewModel(project, projectData, parentCs, initialText), NewGitLabNoteViewModel { override val trackingId: String = UUID.randomUUID().toString() private val preferences = project.service() @@ -145,9 +169,10 @@ private abstract class NewGitLabNoteViewModelBase( private class NewStandaloneGitLabNoteViewModel(project: Project, parentCs: CoroutineScope, initialText: String, + projectData: GitLabProject, private val mergeRequest: GitLabMergeRequest, currentUser: GitLabUserDTO) - : NewGitLabNoteViewModelBase(project, parentCs, initialText, currentUser) { + : NewGitLabNoteViewModelBase(project, parentCs, projectData, initialText, currentUser) { override val canSubmitAsDraft: Boolean = mergeRequest.canAddDraftNotes override suspend fun doSubmit(text: String) = mergeRequest.addNote(text) override suspend fun doSubmitAsDraft(text: String) = mergeRequest.addDraftNote(text) @@ -156,10 +181,11 @@ private class NewStandaloneGitLabNoteViewModel(project: Project, private class NewDiffGitLabNoteViewModel(project: Project, parentCs: CoroutineScope, initialText: String, + projectData: GitLabProject, private val mergeRequest: GitLabMergeRequest, currentUser: GitLabUserDTO, private val position: GitLabMergeRequestNewDiscussionPosition) - : NewGitLabNoteViewModelBase(project, parentCs, initialText, currentUser) { + : NewGitLabNoteViewModelBase(project, parentCs, projectData, initialText, currentUser, ) { override val canSubmitAsDraft: Boolean = mergeRequest.canAddPositionalDraftNotes override suspend fun doSubmit(text: String) = mergeRequest.addNote(position, text) override suspend fun doSubmitAsDraft(text: String) = mergeRequest.addDraftNote(position, text) @@ -168,9 +194,10 @@ private class NewDiffGitLabNoteViewModel(project: Project, private class NewReplyGitLabNoteViewModel(project: Project, parentCs: CoroutineScope, initialText: String, + projectData: GitLabProject, private val discussion: GitLabDiscussion, currentUser: GitLabUserDTO) - : NewGitLabNoteViewModelBase(project, parentCs, initialText, currentUser) { + : NewGitLabNoteViewModelBase(project, parentCs, projectData, initialText, currentUser, ) { override val canSubmitAsDraft: Boolean = discussion.canAddDraftNotes override suspend fun doSubmit(text: String) = discussion.addNote(text) override suspend fun doSubmitAsDraft(text: String) = discussion.addDraftNote(text) diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabNoteViewModel.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabNoteViewModel.kt index ea078fea9c41..f330abe3bbd5 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabNoteViewModel.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/ui/comment/GitLabNoteViewModel.kt @@ -59,7 +59,7 @@ class GitLabNoteViewModelImpl( override val serverUrl: URL = projectData.projectMapping.repository.serverPath.toURL() override val actionsVm: GitLabNoteAdminActionsViewModel? = - if (note is MutableGitLabNote && note.canAdmin) GitLabNoteAdminActionsViewModelImpl(cs, project, note) else null + if (note is MutableGitLabNote && note.canAdmin) GitLabNoteAdminActionsViewModelImpl(cs, project, projectData, note) else null override val reactionsVm: GitLabReactionsViewModel? = if (note is GitLabMergeRequestNote && note.canReact) GitLabReactionsViewModelImpl(cs, projectData, note, currentUser) else null diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/upload/GitLabUploadFileUtil.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/upload/GitLabUploadFileUtil.kt new file mode 100644 index 000000000000..228eb65719ad --- /dev/null +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/upload/GitLabUploadFileUtil.kt @@ -0,0 +1,65 @@ +// 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.gitlab.upload + +import com.intellij.openapi.fileChooser.FileChooser +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.VcsNotifier +import com.intellij.platform.ide.progress.withBackgroundProgress +import kotlinx.coroutines.* +import org.jetbrains.plugins.gitlab.mergerequest.data.GitLabProject +import org.jetbrains.plugins.gitlab.mergerequest.util.localizedMessageOrClassName +import org.jetbrains.plugins.gitlab.notification.GitLabNotificationIds.GL_NOTIFICATION_UPLOAD_FILE_ERROR +import org.jetbrains.plugins.gitlab.util.GitLabBundle.message +import java.nio.file.Path +import java.nio.file.AccessDeniedException +import java.nio.file.NoSuchFileException + + +internal object GitLabUploadFileUtil { + + suspend fun uploadFileAndNotify( + project: Project, + projectData: GitLabProject, + path: Path?, + ): String? { + val resultingPath = path ?: showDialog(project) ?: return null + + return withContext(Dispatchers.IO) { + uploadFileAndNotify(project) { projectData.uploadFile(resultingPath) } + } + } + + private suspend fun uploadFileAndNotify( + project: Project, + uploader: suspend () -> String, + ): String? { + try { + return withBackgroundProgress(project, message("upload.file.action.progress"), true) { + uploader() + } + } + catch (e: CancellationException) { + throw e + } + catch (_: NoSuchFileException) { + VcsNotifier.getInstance(project).notifyError(GL_NOTIFICATION_UPLOAD_FILE_ERROR, message("upload.file.action.error.title"), + message("upload.file.dialog.file.not.found")) + } + catch (_: AccessDeniedException) { + VcsNotifier.getInstance(project).notifyError(GL_NOTIFICATION_UPLOAD_FILE_ERROR, message("upload.file.action.error.title"), + message("upload.file.dialog.not.readable")) + } + catch (e: Exception) { + VcsNotifier.getInstance(project).notifyError(GL_NOTIFICATION_UPLOAD_FILE_ERROR, message("upload.file.action.error.title"), + e.localizedMessageOrClassName()) + } + return null + } + + private suspend fun showDialog(project: Project): Path? { + return withContext(Dispatchers.Main) { + FileChooser.chooseFile(FileChooserDescriptorFactory.singleFile().withTitle(message("upload.file.dialog.title")), project, null)?.toNioPath() + } + } +} \ No newline at end of file diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/upload/GitLabUploadsApi.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/upload/GitLabUploadsApi.kt new file mode 100644 index 000000000000..eb953dbdff31 --- /dev/null +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/upload/GitLabUploadsApi.kt @@ -0,0 +1,46 @@ +// 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.gitlab.upload + +import com.intellij.collaboration.api.json.loadJsonValue +import com.intellij.collaboration.util.resolveRelative +import org.jetbrains.plugins.gitlab.api.GitLabApi +import org.jetbrains.plugins.gitlab.api.GitLabProjectCoordinates +import org.jetbrains.plugins.gitlab.api.SinceGitLab +import org.jetbrains.plugins.gitlab.api.dto.GitLabUploadRestDTO +import org.jetbrains.plugins.gitlab.api.restApiUri +import java.net.http.HttpRequest.BodyPublishers +import java.net.http.HttpResponse +import java.nio.file.Files +import java.nio.file.Path +import java.util.* + + +@SinceGitLab("15.10") +suspend fun GitLabApi.Rest.markdownUploadFile( + project: GitLabProjectCoordinates, + path: Path, +): HttpResponse { + val uri = project.restApiUri.resolveRelative("uploads") + val filename = path.fileName.toString() + + val mimeType = Files.probeContentType(path) ?: "application/octet-stream" + + val boundary = "FormBoundary" + UUID.randomUUID() + val boundaryStart = "--$boundary\r\n" + + "Content-Disposition: form-data; name=\"file\"; filename=\"$filename\"\r\n" + + "Content-Type: $mimeType\r\n\r\n" + val boundaryEnd = "\r\n--$boundary--\r\n" + + val bodyPublisher = BodyPublishers.concat( + BodyPublishers.ofString(boundaryStart), + BodyPublishers.ofInputStream { Files.newInputStream(path) }, + BodyPublishers.ofString(boundaryEnd) + ) + + val httpRequest = request(uri) + .POST(bodyPublisher) + .header("Content-Type", "multipart/form-data; boundary=$boundary") + .build() + + return loadJsonValue(httpRequest) +} \ No newline at end of file