[gitlab] Only show snippet button when files selected

GitOrigin-RevId: 79135ab5867b069f85f80aea3f3c9b16f881b3f1
This commit is contained in:
Chris Lemaire
2023-07-23 12:42:57 +02:00
committed by intellij-monorepo-bot
parent 2c7dfa1889
commit feb9f0936e
12 changed files with 237 additions and 42 deletions

View File

@@ -28,6 +28,7 @@
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/gen" isTestSource="false" generated="true" />
<sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/testResources" type="java-test-resource" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />

View File

@@ -109,8 +109,18 @@
class="org.jetbrains.plugins.gitlab.mergerequest.action.GitLabMergeRequestCopyURLAction"
icon="org.jetbrains.plugins.gitlab.GitlabIcons.GitLabLogo"/>
<action id="GitLab.Create.Snippet"
class="org.jetbrains.plugins.gitlab.snippets.GitLabCreateSnippetAction"
icon="org.jetbrains.plugins.gitlab.GitlabIcons.GitLabLogo">
<add-to-group group-id="EditorPopupMenu"/>
<add-to-group group-id="ProjectViewPopupMenu"/>
<add-to-group group-id="EditorTabPopupMenu"/>
<add-to-group group-id="ConsoleEditorPopupMenu"/>
</action>
<group id="GitLab.Main.Group" popup="true" class="com.intellij.ide.actions.NonTrivialActionGroup">
<reference id="GitLab.Merge.Request.Show.List"/>
<add-to-group group-id="Git.MainMenu" relative-to-action="Git.Configure.Remotes" anchor="before"/>
</group>
@@ -173,13 +183,6 @@
<add-to-group group-id="Git.Hosting.Open.In.Browser.Group"/>
</group>
<action id="GitLab.Create.Snippet" class="org.jetbrains.plugins.gitlab.snippets.GitLabCreateSnippetAction">
<add-to-group group-id="EditorPopupMenu"/>
<add-to-group group-id="ProjectViewPopupMenu"/>
<add-to-group group-id="EditorTabPopupMenu"/>
<add-to-group group-id="ConsoleEditorPopupMenu"/>
</action>
<group id="GitLab.Copy.Link" class="org.jetbrains.plugins.gitlab.ui.action.GitLabCopyLinkActionGroup"
icon="org.jetbrains.plugins.gitlab.GitlabIcons.GitLabLogo">
<override-text place="CopyReferencePopup"/>

View File

@@ -137,6 +137,7 @@ snippet.create.copy-url.label=Copy URL
snippet.create.open-in-browser.label=Open in browser
snippet.create.path-mode=Path handling mode:
snippet.create.path-mode.unavailable.tooltip=This naming scheme causes conflicts
snippet.create.path-mode.tooltip=How paths should be included in file names
snippet.create.path-mode.project-relative=Project relative
snippet.create.path-mode.project-relative.tooltip=Include relative path from project root

View File

@@ -4,17 +4,9 @@ package org.jetbrains.plugins.gitlab.snippets
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.application.EDT
import com.intellij.openapi.application.ReadAction
import com.intellij.openapi.components.service
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.vfs.VirtualFile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import org.jetbrains.plugins.gitlab.GitlabIcons
import org.jetbrains.plugins.gitlab.api.data.GitLabVisibilityLevel
import org.jetbrains.plugins.gitlab.util.GitLabBundle.messagePointer
class GitLabCreateSnippetAction : DumbAwareAction(messagePointer("snippet.create.action.title"),
@@ -28,6 +20,10 @@ class GitLabCreateSnippetAction : DumbAwareAction(messagePointer("snippet.create
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
override fun update(e: AnActionEvent) {
val canOpen = e.getData(CommonDataKeys.PROJECT)
?.service<GitLabSnippetService>()
?.canOpenDialog(e) ?: false
e.presentation.isEnabledAndVisible = canOpen
}
}

View File

@@ -79,7 +79,8 @@ internal object GitLabCreateSnippetComponentFactory {
}
row(message("snippet.create.path-mode")) {
comboBox(PathHandlingMode.values().toList(),
// TODO: Maybe make option unavailable, problem is making the item unselectable
comboBox(createSnippetVm.availablePathModes,
ListCellRenderer { _, value, _, _, _ ->
JLabel(value?.displayName).apply {
toolTipText = value?.tooltip

View File

@@ -38,6 +38,7 @@ class GitLabCreateSnippetViewModel(
private val cs: CoroutineScope,
val project: Project,
val contents: Deferred<List<GitLabSnippetFileContents>>,
val availablePathModes: Set<PathHandlingMode>,
val data: CreateSnippetViewModelData,
) {
/** Flow of GitLab accounts taken from [GitLabAccountManager]. */

View File

@@ -14,6 +14,7 @@ import com.intellij.openapi.project.guessProjectDir
import com.intellij.openapi.roots.ProjectFileIndex
import com.intellij.openapi.vfs.VfsUtilCore
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.isFile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -26,6 +27,10 @@ import java.awt.datatransfer.StringSelection
*/
@Service(Service.Level.PROJECT)
class GitLabSnippetService(private val project: Project, private val serviceScope: CoroutineScope) {
companion object {
const val GL_SNIPPET_FILES_LIMIT = 10
}
/**
* Gets the name of a snippet entry directly from the file name.
*/
@@ -38,7 +43,15 @@ class GitLabSnippetService(private val project: Project, private val serviceScop
fun performCreateSnippetAction(e: AnActionEvent) {
serviceScope.launch(Dispatchers.Default) {
val cs = this
val vm = createVM(cs, e) ?: return@launch // TODO: Display error (and make button unavailable)
val editor = e.getData(CommonDataKeys.EDITOR)
val selectedFile = e.getData(CommonDataKeys.VIRTUAL_FILE)
val selectedFiles = e.getData(CommonDataKeys.VIRTUAL_FILE_ARRAY)?.toList()
val files = collectNonBinaryFiles(listOfNotNull(editor?.virtualFile).ifEmpty { null }
?: selectedFiles
?: listOfNotNull(selectedFile))
val vm = createVM(cs, e, files)
// Await result of dialog
if (!cs.async(Dispatchers.Main) {
@@ -56,19 +69,8 @@ class GitLabSnippetService(private val project: Project, private val serviceScop
val server = vm.getServer() ?: return@launch // TODO: Display error
val contents = vm.contents.await()
val files = contents.mapNotNull { it.file }
val fileNameExtractor = if (files.isEmpty()) {
pathFromName
}
else {
when (data.pathHandlingMode) {
PathHandlingMode.RelativePaths -> pathFromNearestCommonAncestor(files)
PathHandlingMode.ProjectRelativePaths -> pathFromProjectRoot()
PathHandlingMode.ContentRootRelativePaths -> pathFromContentRoot()
PathHandlingMode.FlattenedPaths -> pathFromName
}
}
val fileNameExtractor = getFileNameExtractor(files, data.pathHandlingMode)
val result = api.graphQL.createSnippet(
server,
@@ -92,24 +94,61 @@ class GitLabSnippetService(private val project: Project, private val serviceScop
}
}
/**
* Gets the file name extractor function for the given [PathHandlingMode] using the given set of [files][VirtualFile].
* If the list of files are empty, the name selector used should not matter (and no dialog should be opened anyway),
* the default name selector is then returned, which is to just take the file name as snippet file name.
*/
private fun getFileNameExtractor(files: List<VirtualFile>,
pathHandlingMode: PathHandlingMode): (VirtualFile) -> String =
if (files.isEmpty()) {
pathFromName
}
else {
when (pathHandlingMode) {
PathHandlingMode.RelativePaths -> pathFromNearestCommonAncestor(files)
PathHandlingMode.ProjectRelativePaths -> pathFromProjectRoot()
PathHandlingMode.ContentRootRelativePaths -> pathFromContentRoot()
PathHandlingMode.FlattenedPaths -> pathFromName
}
}
/**
* Checks whether the user should be able to see and click the 'Create Snippet' button.
*/
fun canOpenDialog(e: AnActionEvent): Boolean {
if (project.isDefault) {
return false
}
val editor = e.getData(CommonDataKeys.EDITOR)
val file = e.getData(CommonDataKeys.VIRTUAL_FILE)
val files = collectNonBinaryFiles(e.getData(CommonDataKeys.VIRTUAL_FILE_ARRAY)?.toList()
?: listOfNotNull(file))
if (editor != null) {
return editor.document.textLength != 0
}
return files.size in 1..GL_SNIPPET_FILES_LIMIT
}
/**
* Creates the view-model object for representing the creation of a snippet.
*/
private fun createVM(cs: CoroutineScope, e: AnActionEvent): GitLabCreateSnippetViewModel {
val editor = e.getData(CommonDataKeys.EDITOR) // If editor in focus
val selectedFiles = e.getData(CommonDataKeys.VIRTUAL_FILE_ARRAY) // If multiple files are selected
val selectedFile = e.getData(CommonDataKeys.VIRTUAL_FILE) // If in editor or on file (or on tab in file)
private fun createVM(cs: CoroutineScope, e: AnActionEvent, files: List<VirtualFile>): GitLabCreateSnippetViewModel {
val contents = cs.async(Dispatchers.IO) { collectContents(e) }
val contents = cs.async(Dispatchers.IO) {
ReadAction.computeCancellable<List<GitLabSnippetFileContents>?, Nothing> {
collectContents(editor, selectedFiles, selectedFile) ?: listOf()
}
val availablePathHandlingModes = PathHandlingMode.values().filter {
val extractor = getFileNameExtractor(files, it)
files.map(extractor).toSet().size == files.size // Check that there are no duplicates when mapped
}
return GitLabCreateSnippetViewModel(
cs,
project,
contents,
availablePathHandlingModes.toSet(),
CreateSnippetViewModelData(
"",
"",
@@ -124,6 +163,49 @@ class GitLabSnippetService(private val project: Project, private val serviceScop
)
}
/**
* Collects all non-binary files under the given files or directories, including those files
* or directories. Files are collected and returned as a list, which is guaranteed to contain
* at most [limit] number of elements.
*
* @return The list of collected files with at most [limit] elements.
*/
private fun collectNonBinaryFiles(files: List<VirtualFile>): List<VirtualFile> {
val collection = mutableSetOf<VirtualFile>()
files.forEach {
if (it.collectNonBinaryFilesImpl(collection)) {
return collection.toList()
}
}
return collection.toList()
}
/**
* Collects all non-binary files under this file/directory, including this file if this [VirtualFile]
* is indeed a non-binary file. Files are collected in [collection], which is guaranteed to contain
* exactly [limit]+1 number of elements when `true` is returned, or less than or equal to [limit]
* number of elements when `false` is returned.
*
* @return `true` if the limit is reached, `false`, if not.
*/
private fun VirtualFile.collectNonBinaryFilesImpl(collection: MutableSet<VirtualFile>): Boolean {
if (isFile && fileType.isBinary || collection.size > GL_SNIPPET_FILES_LIMIT || isRecursiveOrCircularSymlink) {
return collection.size > GL_SNIPPET_FILES_LIMIT
}
if (this.isFile) {
collection += this
}
children?.forEach {
if (it.collectNonBinaryFilesImpl(collection)) {
return true
}
}
return false
}
/**
* Gets the name of a snippet entry from the relative path from the content root of a file.
*/
@@ -160,6 +242,23 @@ class GitLabSnippetService(private val project: Project, private val serviceScop
}
}
/**
* Collects the contents that are supposed to be part of the snippet from the given action event.
*
* @return All file contents that are selected and not empty.
*/
private fun collectContents(e: AnActionEvent): List<GitLabSnippetFileContents> {
val editor = e.getData(CommonDataKeys.EDITOR) // If editor in focus
val selectedFiles = e.getData(CommonDataKeys.VIRTUAL_FILE_ARRAY) // If multiple files are selected
val selectedFile = e.getData(CommonDataKeys.VIRTUAL_FILE) // If in editor or on file (or on tab in file)
return ReadAction.computeCancellable<List<GitLabSnippetFileContents>, Nothing> {
collectContents(editor, selectedFiles, selectedFile)
?.filter { it.capturedContents.isNotEmpty() }
?: listOf()
}
}
/**
* Collects the contents from [Editor], or list of [files][VirtualFile], or [file][VirtualFile] in that order.
* The first content holder that is not `null` will be used.
@@ -170,14 +269,11 @@ class GitLabSnippetService(private val project: Project, private val serviceScop
if (editor != null) {
editor.collectContents()?.let(::listOf)
}
else if (files != null) {
files.map { f ->
else {
collectNonBinaryFiles(files?.toList() ?: listOfNotNull(file)).map { f ->
f.collectContents() ?: GitLabSnippetFileContents(f, "")
}
}
else {
file?.collectContents()?.let(::listOf)
}
/**
* Collects the selected contents of a file in the [Editor] as [GitLabSnippetFileContents].

View File

@@ -0,0 +1,86 @@
// 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.snippets
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.components.service
import com.intellij.openapi.editor.Document
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import org.jetbrains.plugins.gitlab.testutil.getGitLabTestDataPath
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import java.nio.file.Path
import kotlin.io.path.absolutePathString
class GitLabSnippetServiceTest : BasePlatformTestCase() {
override fun getTestDataPath(): String {
val path = getGitLabTestDataPath("community/plugins/gitlab/testResources")?.absolutePathString()
requireNotNull(path)
return path
}
fun `test - canOpenDialog checks for nested files`() {
val lfs = LocalFileSystem.getInstance()
val e = mock<AnActionEvent>()
whenever(e.getData(eq(CommonDataKeys.PROJECT))).thenReturn(project)
whenever(e.getData(eq(CommonDataKeys.VIRTUAL_FILE))).thenReturn(
lfs.findFileByNioFile(Path.of(testDataPath, "snippets/1-nested-files")))
assertTrue(project.service<GitLabSnippetService>().canOpenDialog(e))
}
fun `test - canOpenDialog is false for empty files`() {
val lfs = LocalFileSystem.getInstance()
val file = lfs.findFileByNioFile(Path.of(testDataPath, "snippets/2-empty-file/empty.txt"))
val document = mock<Document>()
whenever(document.text).thenReturn("")
whenever(document.textLength).thenReturn(0)
val editor = mock<Editor>()
whenever(editor.virtualFile).thenReturn(file)
whenever(editor.document).thenReturn(document)
val e = mock<AnActionEvent>()
whenever(e.getData(eq(CommonDataKeys.PROJECT))).thenReturn(project)
whenever(e.getData(eq(CommonDataKeys.EDITOR))).thenReturn(editor)
assertFalse(project.service<GitLabSnippetService>().canOpenDialog(e))
}
fun `test - canOpenDialog prefers editor over selected files`() {
val lfs = LocalFileSystem.getInstance()
val emptyFile = lfs.findFileByNioFile(Path.of(testDataPath, "snippets/2-empty-file/empty.txt"))
val nonEmptyFile = lfs.findFileByNioFile(Path.of(testDataPath, "snippets/1-nested-files/example.txt"))
val document = mock<Document>()
whenever(document.text).thenReturn("")
whenever(document.textLength).thenReturn(0)
val editor = mock<Editor>()
whenever(editor.virtualFile).thenReturn(emptyFile)
whenever(editor.document).thenReturn(document)
val e = mock<AnActionEvent>()
whenever(e.getData(eq(CommonDataKeys.PROJECT))).thenReturn(project)
whenever(e.getData(eq(CommonDataKeys.EDITOR))).thenReturn(editor)
whenever(e.getData(eq(CommonDataKeys.VIRTUAL_FILE))).thenReturn(nonEmptyFile)
assertFalse(project.service<GitLabSnippetService>().canOpenDialog(e))
}
fun `test - canOpenDialog is true for directory with empty file`() {
val lfs = LocalFileSystem.getInstance()
val e = mock<AnActionEvent>()
whenever(e.getData(eq(CommonDataKeys.PROJECT))).thenReturn(project)
whenever(e.getData(eq(CommonDataKeys.VIRTUAL_FILE))).thenReturn(
lfs.findFileByNioFile(Path.of(testDataPath, "snippets/2-empty-file")))
assertTrue(project.service<GitLabSnippetService>().canOpenDialog(e))
}
}

View File

@@ -1,6 +1,7 @@
// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.plugins.gitlab.testutil
import com.intellij.openapi.application.PathManager
import kotlinx.coroutines.*
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
@@ -8,6 +9,12 @@ import kotlinx.coroutines.test.setMain
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import java.nio.file.Path
import kotlin.io.path.exists
fun getGitLabTestDataPath(at: String): Path? =
Path.of(PathManager.getHomePath(), at).takeIf { it.exists() } ?:
Path.of(PathManager.getHomePath()).parent.resolve(at).takeIf { it.exists() }
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(private val dispatcher: CoroutineDispatcher = UnconfinedTestDispatcher()) : TestRule {

View File

@@ -0,0 +1,2 @@
some,data,here
another,row,of,data
1 some,data,here
2 another,row,of,data

View File

@@ -0,0 +1 @@
I am an example, hi