[gitlab] IJPL-82619: implement file uploading

* `GitLabProject` now has `GitLabProjectFileUploader` to upload files via Gitlab api
*  added the uploader to AbstractGitLabNoteEditingViewModel

GitOrigin-RevId: 90fdb310d87a3dfbc9c8953f3ed9846dde06e9c1
This commit is contained in:
Valeria Golovina
2025-11-14 13:59:23 +00:00
committed by intellij-monorepo-bot
parent 744d7e56b8
commit 18f683ca1b
18 changed files with 239 additions and 31 deletions
@@ -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
@@ -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()
@@ -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)
@@ -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 <a href="https://docs.gitlab.com/api/project_markdown_uploads/">Markdown uploads API</a>
*/
@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,
)
@@ -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<GitLabProject>()
@@ -52,6 +55,9 @@ interface GitLabProject {
suspend fun adjustReviewers(mrIid: String, reviewers: List<GitLabUserDTO>): 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
@@ -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)
}
@@ -100,7 +100,7 @@ class GitLabMergeRequestTimelineDiscussionViewModelImpl(
override val canCreateReplies: StateFlow<Boolean> = discussion.canAddNotes.stateIn(cs, SharingStarted.Eagerly, false)
override val replyVm: StateFlow<NewGitLabNoteViewModel?> =
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<GitLabDiscussionDiffViewModel?> =
@@ -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<String> = note.body
@@ -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)
}
}
@@ -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<String> {
return listOf()
}
override fun getNotificationIds(): List<String> = listOf(
GitLabNotificationIds.GL_NOTIFICATION_UPLOAD_FILE_ERROR,
)
}
@@ -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
@@ -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<NewGitLabNoteViewModel?> = 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 = ""
}
@@ -64,7 +64,7 @@ internal class GitLabMergeRequestDiscussionViewModelBase(
override val replyVm: StateFlow<GitLabDiscussionReplyViewModel?> =
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<String> = note.body
@@ -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<GitLabNoteAdminActionsViewModel>()
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()
@@ -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<GitLabMergeRequestsPreferences>()
@@ -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)
@@ -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
@@ -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()
}
}
}
@@ -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<out GitLabUploadRestDTO> {
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)
}