mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-01-07 05:09:37 +07:00
[gitlab] Only show snippet button when files selected
GitOrigin-RevId: 79135ab5867b069f85f80aea3f3c9b16f881b3f1
This commit is contained in:
committed by
intellij-monorepo-bot
parent
2c7dfa1889
commit
feb9f0936e
@@ -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" />
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]. */
|
||||
|
||||
@@ -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].
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
some,data,here
|
||||
another,row,of,data
|
||||
|
@@ -0,0 +1 @@
|
||||
I am an example, hi
|
||||
Reference in New Issue
Block a user