Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Kantis/service refactor #308

Merged
merged 11 commits into from
Jun 25, 2024
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ dependencies {
// needed for the resource files which are loaded into java light tests
testImplementation(libs.test.kotest.framework.api)
testImplementation(libs.test.kotest.assertions.core)
runtimeOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
testRuntimeOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
}

sourceSets {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package io.kotest.plugin.intellij.toolwindow

import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.EDT
import com.intellij.openapi.components.Service
import com.intellij.openapi.module.ModuleUtilCore
import com.intellij.openapi.project.DumbService
import com.intellij.openapi.project.NoAccessDuringPsiEvents
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.psi.PsiFile
import com.intellij.psi.PsiManager
import io.kotest.plugin.intellij.findFiles
import io.kotest.plugin.intellij.psi.getAllSuperClasses
import io.kotest.plugin.intellij.psi.isTestFile
import io.kotest.plugin.intellij.psi.specs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.kotlin.idea.core.util.toPsiFile
import org.jetbrains.kotlin.psi.KtClassOrObject
import org.jetbrains.kotlin.psi.KtProperty
import javax.swing.tree.DefaultMutableTreeNode
import javax.swing.tree.DefaultTreeModel
import javax.swing.tree.TreeModel
import kotlin.properties.Delegates.observable

/**
* Manages state related to the test explorer tool window, including:
* - Currently selected options in the UI
* - The current file being displayed
* - The tags found in the project
*/
@Service(Service.Level.PROJECT)
class KotestTestExplorerService(
private val project: Project,
private val scope: CoroutineScope,
) {

// TODO: Remove when dropping IC-223 support.
@Suppress("unused")
constructor(project: Project) : this(project, CoroutineScope(Dispatchers.Default))

var showCallbacks by observable(true) { _, _, _ -> reloadModelInBackgroundThread() }
var showTags by observable(true) { _, _, _ -> reloadModelInBackgroundThread() }
var showModules by observable(true) { _, _, _ -> reloadModelInBackgroundThread() }
var showIncludes by observable(true) { _, _, _ -> reloadModelInBackgroundThread() }
var autoscrollToSource by observable(true) { _, _, _ -> reloadModelInBackgroundThread() }

var tags: List<String> by observable(emptyList()) { _, _, _ -> reloadModelInBackgroundThread() }
var currentFile: VirtualFile? by observable(null) { _, _, _ -> reloadModelInBackgroundThread() }

/**
* Interface used by dependent components to receive updates to the tree model.
*/
interface ModelListener {
fun setModel(treeModel: TreeModel)
}

private val modelListeners = mutableListOf<ModelListener>()
fun registerModelListener(modelListener: ModelListener) { modelListeners.add(modelListener) }

private fun reloadModelInBackgroundThread() {
scope.launch(Dispatchers.Default) {
// TODO: Just use runReadAction function after dropping IC-223
ApplicationManager.getApplication().runReadAction {
reloadModel(currentFile)
}
}
}

private fun reloadModel(
file: VirtualFile?,
retries: Int = 10,
) {
if (file == null || !file.isTestFile(project)) {
broadcastUpdatedModel(noFileModel)
} else {
val module = ModuleUtilCore.findModuleForFile(file, project) ?: return
val psi = PsiManager.getInstance(project).findFile(file) ?: return

return if (DumbService.getInstance(project).isDumb || NoAccessDuringPsiEvents.isInsideEventProcessing()) {
DumbService.getInstance(project).runWhenSmart {
if (retries > 0) {
reloadModel(file, retries - 1)
} else {
noFileModel
}
}
} else {
val specs = psi.specs()
broadcastUpdatedModel(createTreeModel(file, project, specs, module))
}
}
}

private fun broadcastUpdatedModel(model: TreeModel) {
scope.launch(Dispatchers.EDT) {
modelListeners.forEach { it.setModel(model) }
}
}

fun scanTags() {
scope.launch(Dispatchers.Default) {
// TODO: Just use runReadAction function after dropping IC-223
ApplicationManager.getApplication().runReadAction {
tags =
findFiles(project)
.mapNotNull { it.toPsiFile(project) }
.flatMap { it.detectKotestTags() }
.distinct()
.sorted()
}
}
}

/**
* Looks for Kotest tags in this file, defined at the top level as either vals or anon objects.
*/
private fun PsiFile.detectKotestTags(): List<String> =
children.mapNotNull {
when (it) {
is KtClassOrObject -> if (it.getAllSuperClasses().contains(TagSuperClass)) it.name else null
is KtProperty -> it.name
else -> null
}
}

companion object {
private val noFileModel = DefaultTreeModel(DefaultMutableTreeNode("<no test file selected>"))
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.kotest.plugin.intellij.toolwindow

import com.intellij.openapi.project.Project
import com.intellij.psi.NavigatablePsiElement
import javax.swing.event.TreeSelectionEvent
import javax.swing.event.TreeSelectionListener
Expand All @@ -8,12 +9,15 @@ import javax.swing.event.TreeSelectionListener
* Listens to [TreeSelectionEvent]s which are fired when the user clicks on nodes in
* the test file tree.
*/
object TestExplorerTreeSelectionListener : TreeSelectionListener {
class TestExplorerTreeSelectionListener(
val project: Project,
) : TreeSelectionListener {
private val kotestTestExplorerService: KotestTestExplorerService = project.getService(KotestTestExplorerService::class.java)

override fun valueChanged(e: TreeSelectionEvent) {
// this event is also fired when the path is "unselected" by clicking inside the editor
// and isAddedPath will return false for that scenario (which we don't want to react to)
if (e.isAddedPath && TestExplorerState.autoscrollToSource) {
if (e.isAddedPath && kotestTestExplorerService.autoscrollToSource) {
val psi = when (val node = e.path.nodeDescriptor()) {
is SpecNodeDescriptor -> node.psi
is CallbackNodeDescriptor -> node.psi
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import java.awt.event.MouseEvent
* The main panel for the test explorer 'tool window'.
*/
class TestExplorerWindow(private val project: Project) : SimpleToolWindowPanel(true, false) {
val kotestTestExplorerService: KotestTestExplorerService = project.getService(KotestTestExplorerService::class.java)

private val fileEditorManager = FileEditorManager.getInstance(project)
private val tree = TestFileTree(project)
Expand All @@ -37,7 +38,7 @@ class TestExplorerWindow(private val project: Project) : SimpleToolWindowPanel(t
})

background = JBColor.WHITE
toolbar = createToolbar(tree, project)
toolbar = createToolbar(this, tree, project)
setContent(ScrollPaneFactory.createScrollPane(tree))
listenForSelectedEditorChanges()
listenForFileChanges()
Expand All @@ -55,7 +56,7 @@ class TestExplorerWindow(private val project: Project) : SimpleToolWindowPanel(t
val files = events.mapNotNull { it.file }
val modified = files.firstOrNull { it.name == selectedFile.name }
if (modified != null)
tree.setVirtualFile(modified)
kotestTestExplorerService.currentFile = modified
}
}
}
Expand All @@ -73,7 +74,7 @@ class TestExplorerWindow(private val project: Project) : SimpleToolWindowPanel(t
override fun selectionChanged(event: FileEditorManagerEvent) {
val file = fileEditorManager.selectedEditor?.file
if (file != null) {
tree.setVirtualFile(file)
kotestTestExplorerService.currentFile = file
}
}
}
Expand All @@ -87,7 +88,7 @@ class TestExplorerWindow(private val project: Project) : SimpleToolWindowPanel(t
val selectedFile = fileEditorManager.selectedEditor?.file
if (selectedFile != null) {
if (file.virtualFile.name == selectedFile.name) {
tree.setVirtualFile(file.virtualFile)
kotestTestExplorerService.currentFile = file.virtualFile
}
}
}
Expand All @@ -101,8 +102,9 @@ class TestExplorerWindow(private val project: Project) : SimpleToolWindowPanel(t
}

private fun refreshContent() {
scanTags(project)
val file = fileEditorManager.selectedEditor?.file
tree.setVirtualFile(file)
kotestTestExplorerService.scanTags()
fileEditorManager.selectedEditor?.file?.let {
kotestTestExplorerService.currentFile = it
}
}
}
Original file line number Diff line number Diff line change
@@ -1,80 +1,35 @@
package io.kotest.plugin.intellij.toolwindow

import com.intellij.ide.util.treeView.NodeRenderer
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.module.ModuleUtilCore
import com.intellij.openapi.project.DumbService
import com.intellij.openapi.project.NoAccessDuringPsiEvents
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.psi.PsiManager
import io.kotest.plugin.intellij.psi.isTestFile
import io.kotest.plugin.intellij.psi.specs
import javax.swing.tree.DefaultMutableTreeNode
import javax.swing.tree.DefaultTreeModel
import javax.swing.tree.TreeModel
import javax.swing.tree.TreeSelectionModel

class TestFileTree(private val project: Project) : com.intellij.ui.treeStructure.Tree() {

// the last file set on the editor, might not be the same as the currently selected file
// because it is only changed as we navigate to test files.
private var file: VirtualFile? = null
class TestFileTree(
project: Project,
) : com.intellij.ui.treeStructure.Tree(),
KotestTestExplorerService.ModelListener {
private val testExplorerTreeSelectionListener = TestExplorerTreeSelectionListener(project)
private val kotestTestExplorerService: KotestTestExplorerService = project.getService(KotestTestExplorerService::class.java)

init {
selectionModel.selectionMode = TreeSelectionModel.SINGLE_TREE_SELECTION
showsRootHandles = true
isRootVisible = false
cellRenderer = NodeRenderer()
// listens to changes in the selections
addTreeSelectionListener(TestExplorerTreeSelectionListener)
}

/**
* Changes the file for this tree and then refreshes the model.
*/
fun setVirtualFile(file: VirtualFile?) {
this.file = file
reloadModel()
addTreeSelectionListener(testExplorerTreeSelectionListener)
kotestTestExplorerService.registerModelListener(this)
}

/**
* Reloads the model based on the currently set file (if any).
*/
fun reloadModel() {
when (val f = file) {
null -> noFileModel()
else -> reloadModel(f)
}
}

private fun reloadModel(file: VirtualFile, retries: Int = 10) {
if (!file.isTestFile(project)) {
model = noFileModel()
} else {
val module = ModuleUtilCore.findModuleForFile(file, project) ?: return
val psi = PsiManager.getInstance(project).findFile(file) ?: return
if (DumbService.getInstance(project).isDumb || NoAccessDuringPsiEvents.isInsideEventProcessing()) {
DumbService.getInstance(project).runWhenSmart {
if (retries > 0)
reloadModel(file, retries - 1)
}
} else {
val specs = psi.specs()
val expanded = isExpanded(0)
model = createTreeModel(file, project, specs, module)
expandAllNodes()
setModuleGroupNodeExpandedState(expanded)
}
}
override fun setModel(treeModel: TreeModel) {
val expanded = isExpanded(0)
super.setModel(treeModel)
expandAllNodes()
setModuleGroupNodeExpandedState(expanded)
}

private fun setModuleGroupNodeExpandedState(expanded: Boolean) {
if (expanded) expandRow(0) else collapseRow(0)
}

private fun noFileModel(): TreeModel {
val root = DefaultMutableTreeNode("<no test file selected>")
return DefaultTreeModel(root)
}
}
35 changes: 4 additions & 31 deletions src/main/kotlin/io/kotest/plugin/intellij/toolwindow/tags.kt
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
package io.kotest.plugin.intellij.toolwindow

import com.intellij.openapi.project.DumbService
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiFile
import com.intellij.psi.PsiTreeAnyChangeAbstractAdapter
import io.kotest.plugin.intellij.findFiles
import io.kotest.plugin.intellij.psi.getAllSuperClasses
import org.jetbrains.kotlin.idea.core.util.toPsiFile
import org.jetbrains.kotlin.name.FqName
import org.jetbrains.kotlin.psi.KtClassOrObject
import org.jetbrains.kotlin.psi.KtProperty

const val TagsFilename = "KotestTags.kt"
val TagSuperClass = FqName("io.kotest.core.Tag")
Expand All @@ -21,36 +15,15 @@ val TagSuperClass = FqName("io.kotest.core.Tag")
*/
class KotestTagFileListener(
private val tree: TestFileTree,
private val project: Project,
project: Project,
) : PsiTreeAnyChangeAbstractAdapter() {
private val kotestTestExplorerService: KotestTestExplorerService = project.getService(KotestTestExplorerService::class.java)

override fun onChange(file: PsiFile?) {
if (file == null) return
if (file.name == TagsFilename) {
scanTags(project)
tree.reloadModel()
kotestTestExplorerService.scanTags()
}
}
}

/**
* Looks for Kotest tags in this file, defined at the top level as either vals or anon objects.
*/
fun PsiFile.detectKotestTags(): List<String> {
return children.mapNotNull {
when (it) {
is KtClassOrObject -> if (it.getAllSuperClasses().contains(TagSuperClass)) it.name else null
is KtProperty -> it.name
else -> null
}
}
}

fun scanTags(project: Project) {
DumbService.getInstance(project).runWhenSmart {
TestExplorerState.tags = findFiles(project)
.mapNotNull { it.toPsiFile(project) }
.flatMap { it.detectKotestTags() }
.distinct()
.sorted()
}
}
Loading