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