diff --git a/CHANGELOG.md b/CHANGELOG.md index c98cca8..6991e8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ # ngxs Changelog ## [Unreleased] +### Added +- #20 - Code insights/Quickfix when an Action has no implementation in the *.state.ts + +### Fixed +- Duplicate Actions will not show in gutter + +### Changed +- ActionIcon - increased size. ## [0.0.3] - 2023-09-08 diff --git a/gradle.properties b/gradle.properties index 4335ab2..de1e76a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ pluginGroup = com.github.dinbtechit.ngxs pluginName = ngxs pluginRepositoryUrl = https://github.com/dinbtechit/ngxs # SemVer format -> https://semver.org -pluginVersion = 0.0.3 +pluginVersion = 0.0.4 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html pluginSinceBuild = 223 @@ -16,7 +16,7 @@ platformVersion = 2022.3.3 # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html # Example: platformPlugins = com.intellij.java, com.jetbrains.php:203.4449.22 -platformPlugins = JavaScript, PsiViewer:2022.3 +platformPlugins = JavaScript, PsiViewer:2022.3, com.github.dinbtechit.vscodetheme:1.10.2 # Gradle Releases -> https://github.com/gradle/gradle/releases gradleVersion = 8.3 diff --git a/src/main/kotlin/com/github/dinbtechit/ngxs/NgxsIcons.kt b/src/main/kotlin/com/github/dinbtechit/ngxs/NgxsIcons.kt index f6d69ae..836d04f 100644 --- a/src/main/kotlin/com/github/dinbtechit/ngxs/NgxsIcons.kt +++ b/src/main/kotlin/com/github/dinbtechit/ngxs/NgxsIcons.kt @@ -14,6 +14,6 @@ object NgxsIcons { object Gutter { @JvmField val Action = IconLoader.getIcon("icons/ngxs-action.svg", javaClass) - val MutipleActions = IconLoader.getIcon("icons/ngxs-multiple-action.svg", javaClass) + val MultipleActions = IconLoader.getIcon("icons/ngxs-multiple-action.svg", javaClass) } } diff --git a/src/main/kotlin/com/github/dinbtechit/ngxs/action/editor/NgxsActionLineMarkerIconProvider.kt b/src/main/kotlin/com/github/dinbtechit/ngxs/action/editor/NgxsActionLineMarkerIconProvider.kt index 05bca93..66f52a3 100644 --- a/src/main/kotlin/com/github/dinbtechit/ngxs/action/editor/NgxsActionLineMarkerIconProvider.kt +++ b/src/main/kotlin/com/github/dinbtechit/ngxs/action/editor/NgxsActionLineMarkerIconProvider.kt @@ -4,6 +4,7 @@ import com.github.dinbtechit.ngxs.NgxsIcons import com.intellij.codeInsight.daemon.GutterIconNavigationHandler import com.intellij.codeInsight.daemon.LineMarkerInfo import com.intellij.codeInsight.daemon.LineMarkerProvider +import com.intellij.lang.ecmascript6.psi.impl.ES6FieldStatementImpl import com.intellij.lang.javascript.psi.JSReferenceExpression import com.intellij.lang.javascript.psi.ecma6.ES6Decorator import com.intellij.lang.javascript.types.TypeScriptNewExpressionElementType @@ -31,9 +32,12 @@ import javax.swing.JComponent class NgxsActionLineMarkerIconProvider : LineMarkerProvider { override fun getLineMarkerInfo(element: PsiElement): LineMarkerInfo? { - if (element.parent is JSReferenceExpression - && element.parent.parent.elementType is TypeScriptNewExpressionElementType - && element.parent.reference?.resolve()?.containingFile?.name?.contains(".actions.ts") == true + if ((element.parent is JSReferenceExpression + && element.parent.parent.elementType is TypeScriptNewExpressionElementType + && element.parent.reference?.resolve() !== null) && + element.parent.reference?.resolve()!!.children.any { + it is ES6FieldStatementImpl && it.text.contains("^static(.*)type".toRegex()) + } ) { val lineNumber = getLineNumber(element) @@ -48,7 +52,7 @@ class NgxsActionLineMarkerIconProvider : LineMarkerProvider { if (elements.first() != element) return null - val icon = if (navigateToElements.size > 1) NgxsIcons.Gutter.MutipleActions + val icon = if (navigateToElements.size > 1) NgxsIcons.Gutter.MultipleActions else NgxsIcons.Gutter.Action val tooltipText = if (navigateToElements.size > 1) "NGXS Multiple Actions" @@ -74,7 +78,7 @@ class NgxsActionLineMarkerIconProvider : LineMarkerProvider { return GutterIconNavigationHandler { e, _ -> val group = DefaultActionGroup() - for (navElement in navigateToElements) { + for (navElement in navigateToElements.distinctBy { it.text }) { val action = object : AnAction({ "NGXS Action \"${navElement.text}\"" }, NgxsIcons.Gutter.Action) { override fun actionPerformed(e: AnActionEvent) { navigateToElement(navElement) @@ -112,8 +116,8 @@ class NgxsActionLineMarkerIconProvider : LineMarkerProvider { for (ref in refs.toList()) { val actionDecoratorElement = PsiTreeUtil.findFirstParent(ref.element) { it is ES6Decorator } val hasActionDecorator = actionDecoratorElement != null - if (hasActionDecorator && - ref.element.containingFile.name.contains(".state.ts") + if (hasActionDecorator + && ref.element.containingFile.name.contains(".state.ts") ) { val fileEditorManager = FileEditorManager.getInstance(element.project) val textEditor = fileEditorManager.openTextEditor( @@ -124,7 +128,7 @@ class NgxsActionLineMarkerIconProvider : LineMarkerProvider { ) val start = ref.element.textRange.startOffset textEditor?.caretModel?.moveToOffset(start) - textEditor?.scrollingModel?.scrollToCaret(ScrollType.MAKE_VISIBLE) + textEditor?.scrollingModel?.scrollToCaret(ScrollType.CENTER) } } } @@ -152,7 +156,9 @@ class NgxsActionLineMarkerIconProvider : LineMarkerProvider { if (element != null) { if (element.parent is JSReferenceExpression && element.parent.parent.elementType is TypeScriptNewExpressionElementType - && element.parent.reference?.resolve()?.containingFile?.name?.contains(".actions.ts") == true + && element.parent.reference?.resolve()!!.children.any { + it is ES6FieldStatementImpl && it.text.contains("^static(.*)type".toRegex()) + } ) { res.add(element) } diff --git a/src/main/kotlin/com/github/dinbtechit/ngxs/action/editor/NgxsActionUtil.kt b/src/main/kotlin/com/github/dinbtechit/ngxs/action/editor/NgxsActionUtil.kt new file mode 100644 index 0000000..62247c3 --- /dev/null +++ b/src/main/kotlin/com/github/dinbtechit/ngxs/action/editor/NgxsActionUtil.kt @@ -0,0 +1,70 @@ +package com.github.dinbtechit.ngxs.action.editor + +import com.intellij.lang.ecmascript6.psi.impl.ES6FieldStatementImpl +import com.intellij.lang.javascript.psi.JSReferenceExpression +import com.intellij.lang.javascript.psi.ecma6.ES6Decorator +import com.intellij.lang.javascript.types.TypeScriptClassElementType +import com.intellij.lang.javascript.types.TypeScriptNewExpressionElementType +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiWhiteSpace +import com.intellij.psi.search.searches.ReferencesSearch +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.psi.util.elementType +import com.intellij.psi.util.nextLeafs + +object NgxsActionUtil { + + fun isActionDispatched(element: PsiElement): Boolean { + return (element.parent is JSReferenceExpression + && element.parent.parent.elementType is TypeScriptNewExpressionElementType + && element.parent.reference?.resolve() !== null) && + element.parent.reference?.resolve()!!.children.any { + it is ES6FieldStatementImpl && it.text.contains("^static(.*)type".toRegex()) + } + } + + fun isActionClass(element: PsiElement): Boolean { + return element.elementType is TypeScriptClassElementType && element.children.any { + it is ES6FieldStatementImpl && it.text.contains("^static(.*)type".toRegex()) + } + } + + fun isActionImplExist(psiElement: PsiElement): Boolean { + return when { + isActionDispatched(psiElement) -> { + val element2 = psiElement.parent.reference?.resolve()?.navigationElement + this.findActionUsages(element2) + } + + isActionClass(psiElement) -> { + this.findActionUsages(psiElement) + } + + else -> false + } + } + + fun getActionClassPsiElement(element: PsiElement): PsiElement? { + val endIndex = element.firstChild.nextLeafs.indexOfFirst { it.text == "{" } + return element.firstChild.nextLeafs.toList() + .subList(0, if (endIndex < 0) 0 else endIndex) + .firstOrNull { it !is PsiWhiteSpace && it.text != "class" } + } + + fun findActionUsages(element: PsiElement?): Boolean { + + if (element == null) return false + + val refs = ReferencesSearch.search(element).findAll() + for (ref in refs.toList()) { + val actionDecoratorElement = PsiTreeUtil.findFirstParent(ref.element) { it is ES6Decorator } + val hasActionDecorator = actionDecoratorElement != null + if (hasActionDecorator + && ref.element.containingFile.name.contains(".state.ts") + ) { + return true + } + } + return false + } +} diff --git a/src/main/kotlin/com/github/dinbtechit/ngxs/action/editor/NgxsStatePsiFile.kt b/src/main/kotlin/com/github/dinbtechit/ngxs/action/editor/NgxsStatePsiFile.kt new file mode 100644 index 0000000..121e675 --- /dev/null +++ b/src/main/kotlin/com/github/dinbtechit/ngxs/action/editor/NgxsStatePsiFile.kt @@ -0,0 +1,85 @@ +package com.github.dinbtechit.ngxs.action.editor + +import com.intellij.lang.javascript.psi.ecma6.impl.TypeScriptFunctionImpl +import com.intellij.lang.javascript.psi.ecmal4.JSAttributeList +import com.intellij.lang.javascript.types.TypeScriptClassElementType +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.editor.Document +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.TextRange +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiManager +import com.intellij.psi.codeStyle.CodeStyleManager +import com.intellij.psi.util.elementType +import com.intellij.refactoring.suggested.endOffset +import java.util.* + +class NgxsStatePsiFile( + private val ngxsStatePsiFile: VirtualFile, + val project: Project +) { + + fun getTypeFromStateAnnotation(): String? { + val stateClassPsi = getStateClassElement()?.children?.firstOrNull() + if (stateClassPsi !is JSAttributeList) return null + val regex = Regex("<(.*?)>") + val matchResult = regex.find(stateClassPsi.text) + return matchResult?.groups?.get(1)?.value + } + + fun getStateClassElement(): PsiElement? { + return PsiManager.getInstance(project).findFile(ngxsStatePsiFile) + ?.children?.firstOrNull { + it.elementType is TypeScriptClassElementType + && it.children[0] is JSAttributeList + && it.text.contains("@State") + } + } + + fun createActionMethod(actionPsiElement: PsiElement): PsiElement? { + val stateClassPsi = getStateClassElement() + if (stateClassPsi != null) { + if (stateClassPsi.node.lastChildNode.text == "}") { + val lastFunction = stateClassPsi.children.lastOrNull { it is TypeScriptFunctionImpl } + if (lastFunction != null) { + val elementText = """ + @Action(${actionPsiElement.text}) + ${actionPsiElement.text.toCamelCase()}(ctx: StateContext<${getTypeFromStateAnnotation()}>) { + // TODO implement action + } + """.trimIndent() + + val document: Document = FileDocumentManager.getInstance().getDocument(ngxsStatePsiFile) ?: return null + + WriteCommandAction.runWriteCommandAction(project) { + // Check where to insert the new code + val insertOffset: Int = lastFunction.endOffset + // Insert the new code + document.insertString(insertOffset, "\n${elementText}") + PsiDocumentManager.getInstance(project).commitDocument(document) + PsiManager.getInstance(project).findFile(ngxsStatePsiFile)?.let { psiFile -> + val length = psiFile.textLength + val range = TextRange.from(insertOffset, length - insertOffset) + + CodeStyleManager.getInstance(project) + .reformatText(psiFile, range.startOffset, range.endOffset) + } + } + FileDocumentManager.getInstance().saveDocument(document) + return stateClassPsi.children.lastOrNull { it is TypeScriptFunctionImpl } + } + } + } + return null + } + + fun String.toCamelCase(): String = split(" ").joinToString("") { it.replaceFirstChar { + if (it.isLowerCase()) it.titlecase( + Locale.getDefault() + ) else it.toString() + } }.replaceFirstChar { it.lowercase(Locale.getDefault()) } + +} diff --git a/src/main/kotlin/com/github/dinbtechit/ngxs/action/editor/codeIntellisense/NgxsAnnotator.kt b/src/main/kotlin/com/github/dinbtechit/ngxs/action/editor/codeIntellisense/NgxsAnnotator.kt new file mode 100644 index 0000000..f8c4a49 --- /dev/null +++ b/src/main/kotlin/com/github/dinbtechit/ngxs/action/editor/codeIntellisense/NgxsAnnotator.kt @@ -0,0 +1,92 @@ +package com.github.dinbtechit.ngxs.action.editor.codeIntellisense + +import com.github.dinbtechit.ngxs.action.editor.NgxsActionUtil +import com.intellij.codeInspection.ProblemHighlightType +import com.intellij.lang.annotation.AnnotationHolder +import com.intellij.lang.annotation.Annotator +import com.intellij.lang.annotation.HighlightSeverity +import com.intellij.lang.javascript.psi.ecmal4.JSAttributeList +import com.intellij.lang.javascript.types.TypeScriptClassElementType +import com.intellij.openapi.util.TextRange +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiManager +import com.intellij.psi.util.elementType +import com.intellij.refactoring.suggested.endOffset +import com.intellij.refactoring.suggested.startOffset + + +class NgxsAnnotator : Annotator { + + override fun annotate(element: PsiElement, holder: AnnotationHolder) { + var isImplementationExist = true + var problemType = ProblemHighlightType.GENERIC_ERROR_OR_WARNING + var range = TextRange(element.textRange.startOffset, element.textRange.endOffset) + var actionPsiClass: PsiElement? = null + var actionName: String? = null + var actionFileName: String? = null + var actionVirtualFile: VirtualFile? = null + + if (NgxsActionUtil.isActionClass(element)) { + isImplementationExist = NgxsActionUtil.isActionImplExist(element) + problemType = ProblemHighlightType.LIKE_UNUSED_SYMBOL + try { + val classNamePsiElement = NgxsActionUtil.getActionClassPsiElement(element) + if (classNamePsiElement != null) { + range = TextRange(classNamePsiElement.startOffset, classNamePsiElement.endOffset) + actionPsiClass = classNamePsiElement + actionName = classNamePsiElement.text + actionFileName = classNamePsiElement.containingFile.name + actionVirtualFile = classNamePsiElement.containingFile.containingDirectory.virtualFile + } + } catch (e: Exception) { + throw Exception("NgxsAnnotator - Unable to establish range for the ActionClass - ${element.text}") + } + + } else if (NgxsActionUtil.isActionDispatched(element)) { + isImplementationExist = NgxsActionUtil.isActionImplExist(element) + val refElement = element.parent.reference?.resolve() + if (refElement != null) { + val classNamePsiElement = NgxsActionUtil.getActionClassPsiElement(refElement) + if (classNamePsiElement != null) { + actionName = classNamePsiElement.text + actionPsiClass = classNamePsiElement + actionFileName = refElement.containingFile.name + actionVirtualFile = classNamePsiElement.containingFile.containingDirectory.virtualFile + } + } + } + + if (!isImplementationExist ) { + val stateFileName = if (actionFileName != null) + "${actionFileName.split(".")[0]}.state.ts" + else "*.state.ts" + val stateFile = LocalFileSystem.getInstance().findFileByPath("${actionVirtualFile?.path}/$stateFileName") + if (stateFile != null) { + val stateClassPsi = PsiManager.getInstance(element.project).findFile(stateFile)?.children?.firstOrNull { + it.elementType is TypeScriptClassElementType + && it.children[0] is JSAttributeList + && it.text.contains("@State") + } + if (stateClassPsi != null) { + if (stateClassPsi.node.lastChildNode.text == "}") { + stateClassPsi.node.lastChildNode + } + + } + } + + if (actionName == null) actionName = element.text + holder.newAnnotation(HighlightSeverity.WARNING, "@Action(${actionName}) not found in $stateFileName") + .range(range) + .highlightType(problemType) + .withFix( + NgxsCreateActionQuickFix("Create @Action(${actionName}) in $stateFileName.", + actionPsiClass!!, stateFile)) + .create() + } + + } +} + diff --git a/src/main/kotlin/com/github/dinbtechit/ngxs/action/editor/codeIntellisense/NgxsCreateActionQuickFix.kt b/src/main/kotlin/com/github/dinbtechit/ngxs/action/editor/codeIntellisense/NgxsCreateActionQuickFix.kt new file mode 100644 index 0000000..ea0bd67 --- /dev/null +++ b/src/main/kotlin/com/github/dinbtechit/ngxs/action/editor/codeIntellisense/NgxsCreateActionQuickFix.kt @@ -0,0 +1,46 @@ +package com.github.dinbtechit.ngxs.action.editor.codeIntellisense + +import com.github.dinbtechit.ngxs.action.editor.NgxsStatePsiFile +import com.intellij.codeInsight.intention.impl.BaseIntentionAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.ScrollType +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile + +class NgxsCreateActionQuickFix(private val key: String, + private val actionPsiElement: PsiElement, + private val ngxsStateVirtualFile: VirtualFile?) : BaseIntentionAction() { + + override fun getText(): String { + return key + } + + + override fun getFamilyName(): String { + return "Create action" + } + + override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?): Boolean { + return ngxsStateVirtualFile != null + } + + override fun invoke(project: Project, editor: Editor?, file: PsiFile?) { + val actionFunction = NgxsStatePsiFile(ngxsStateVirtualFile!!, project).createActionMethod(actionPsiElement) + val fileEditorManager = FileEditorManager.getInstance(project) + val textEditor = fileEditorManager.openTextEditor( + OpenFileDescriptor( + project, + ngxsStateVirtualFile + ), true + ) + + val start = actionFunction?.textRange?.startOffset ?: 0 + textEditor?.caretModel?.moveToOffset(start) + textEditor?.scrollingModel?.scrollToCaret(ScrollType.CENTER) + + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 1fda4ce..001c32c 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -13,6 +13,7 @@ + - - - - + + + + + diff --git a/src/main/resources/icons/ngxs-action_dark.svg b/src/main/resources/icons/ngxs-action_dark.svg index 844f221..4fcff4a 100644 --- a/src/main/resources/icons/ngxs-action_dark.svg +++ b/src/main/resources/icons/ngxs-action_dark.svg @@ -1,6 +1,6 @@ - - - - - + + + + +