First iteration of the IntelliJ plugin for the flow snapshots tool (#20)

* First iteration of the IntelliJ plugin for the flow snapshots tool

* Addressing review comments

* Addressing review comments round 2
This commit is contained in:
mkit 2017-08-14 14:00:55 +01:00 committed by Michal Kit
parent 3ae53683ea
commit f5fc4ceeb8
15 changed files with 575 additions and 8 deletions

View File

@ -25,6 +25,11 @@ intellij {
buildDir = "$projectDir/build"
}
dependencies {
// For JSON
compile "com.fasterxml.jackson.core:jackson-databind:2.8.5"
}
group 'net.corda'
version '0.1.0' // Plugin version

View File

@ -14,6 +14,11 @@ Corda Intellij Plugin is a plugin for Intellij Idea IDE which aid Corda applicat
You can run or debug the project using provided Intellij Run Configuration `CordaPlugin` or by using the gradle command
`./gradlew runIde` IDE's log file is located in `build/idea-sandbox/system/log/idea.log`
###Corda Flow Tool
After the IntelliJ (with the Corda plugin) is started, go to View/Tool Windows in the menu bar and in the list of
available tools you should see the 'Corda Flow Tool'. In the tool window, point to the flow snapshot location
(or node's base directory) and you will be able to see and examine all available flow snapshots.
## TODOs
* Create a higher quality Corda icon.
* Create a more compact kotlin CorDapp template.

View File

@ -1,4 +1,4 @@
package net.corda.ideaPlugin.module
package net.corda.ideaplugin.module
import com.intellij.ide.fileTemplates.FileTemplateManager
import com.intellij.ide.projectWizard.ProjectSettingsStep

View File

@ -1,4 +1,4 @@
package net.corda.ideaPlugin.module
package net.corda.ideaplugin.module
import com.intellij.openapi.module.ModuleType
import com.intellij.openapi.util.IconLoader
@ -6,13 +6,14 @@ import com.intellij.openapi.util.IconLoader
class CordaModuleType : ModuleType<CordaModuleBuilder>(ID) {
companion object {
val ID = "CORDA_MODULE"
val CORDA_ICON = IconLoader.getIcon("/images/corda-icon.png")
val CORDA_ICON = IconLoader.getIcon("/images/corda-module-icon.png")
val CORDA_ICON_BIG = IconLoader.getIcon("/images/corda-module-icon@2x.png")
val instance = CordaModuleType()
}
override fun createModuleBuilder() = CordaModuleBuilder()
override fun getNodeIcon(p0: Boolean) = CORDA_ICON
override fun getBigIcon() = CORDA_ICON
override fun getBigIcon() = CORDA_ICON_BIG
override fun getName() = "CorDapp"
override fun getDescription() = "Corda DLT Platform Application"
}

View File

@ -1,4 +1,4 @@
package net.corda.ideaPlugin.module
package net.corda.ideaplugin.module
import com.intellij.ide.util.projectWizard.ModuleWizardStep
import com.intellij.openapi.options.ConfigurationException

View File

@ -1,4 +1,4 @@
package net.corda.ideaPlugin.module
package net.corda.ideaplugin.module
import com.intellij.openapi.util.Version
import com.intellij.ui.ColoredListCellRenderer

View File

@ -1,4 +1,4 @@
package net.corda.ideaPlugin.module
package net.corda.ideaplugin.module
object CordaTemplateProvider {
fun getTemplateFiles(cordaTemplate: CordaTemplate, language: Language): List<TemplateFile> {

View File

@ -0,0 +1,212 @@
package net.corda.ideaplugin.toolwindow
import com.intellij.icons.AllIcons
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowFactory
import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.content.Content
import com.intellij.ui.content.ContentFactory
import com.intellij.ui.treeStructure.Tree
import net.corda.ideaplugin.module.CordaModuleType
import java.awt.*
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.io.File
import javax.swing.*
import javax.swing.tree.*
/**
* GUI class for the Corda Flow tool
*/
class CordaFlowToolWindow : ToolWindowFactory {
// Left-hand side tree view
private val flowTree = Tree()
// Right-hand side tree view
private val snapshotTree = Tree()
// Main panel
private val panel = JPanel(GridBagLayout())
private val textField = JTextField()
private val browseButton = JButton()
private val refreshButton = JButton()
private val snapshotDataManager = FlowSnapshotTreeDataManager(snapshotTree)
private val flowDataManager = FlowTreeDataManager(flowTree, snapshotDataManager)
init {
setUpSnapshotTree()
setUpFlowTree()
setUpBrowseButton()
setUpRefreshButton()
setUpTextField()
// Laying-out left-hand side panel
val flowPanel = JPanel(BorderLayout(3, 3))
flowPanel.add(BorderLayout.NORTH, JLabel("Flows"))
val flowScrollPane = JBScrollPane(flowTree)
flowScrollPane.border = BorderFactory.createEmptyBorder()
flowPanel.add(BorderLayout.CENTER, flowScrollPane)
// Laying-out right-hand side panel
val snapshotPanel = JPanel(BorderLayout(3, 3))
snapshotPanel.add(BorderLayout.NORTH, JLabel("Snapshots"))
val snapshotScrollPane = JBScrollPane(snapshotTree)
snapshotScrollPane.border = BorderFactory.createEmptyBorder()
snapshotPanel.add(BorderLayout.CENTER, snapshotScrollPane)
// Horizontal divider
val splitPane = JSplitPane(JSplitPane.HORIZONTAL_SPLIT, flowPanel, snapshotPanel)
splitPane.dividerSize = 2
splitPane.resizeWeight = 0.5
splitPane.border = BorderFactory.createEmptyBorder()
// Container for the text field and buttons
val topPanel = JPanel()
topPanel.layout = BoxLayout(topPanel, BoxLayout.LINE_AXIS)
topPanel.add(textField)
topPanel.add(browseButton)
topPanel.add(refreshButton)
GridBagConstraints().apply {
gridwidth = 1
weightx = 0.05
weighty = 0.05
fill = GridBagConstraints.HORIZONTAL // Fill cell in both direction
insets = Insets(3, 3, 3, 3)
gridy = 0
panel.add(topPanel, this)
}
GridBagConstraints().apply {
gridwidth = 1
weightx = 1.0 // Cell takes up all extra horizontal space
weighty = 1.0 // Cell takes up all extra vertical space
fill = GridBagConstraints.BOTH // Fill cell in both direction
insets = Insets(3, 3, 3, 3)
gridy = 1
panel.add(splitPane, this)
}
}
// Create the tool window content.
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
val contentFactory: ContentFactory = ContentFactory.SERVICE.getInstance()
val content: Content = contentFactory.createContent(panel, "", false)
toolWindow.contentManager.addContent(content)
}
private fun setUpSnapshotTree() {
snapshotTree.isRootVisible = false
snapshotTree.cellRenderer = SnapshotTreeRenderer()
}
private fun setUpFlowTree() {
flowTree.isRootVisible = false
flowTree.selectionModel = LeafOnlyTreeSelectionModel()
flowTree.selectionModel.selectionMode = TreeSelectionModel.SINGLE_TREE_SELECTION
flowTree.cellRenderer = FlowTreeRenderer()
flowTree.addTreeSelectionListener {
val node = flowTree.lastSelectedPathComponent
if (node is DefaultMutableTreeNode) {
val dir = node.userObject as File
if (dir.exists()) {
snapshotDataManager.loadSnapshots(dir.listFiles().filter {
!it.isDirectory && it.name.startsWith(FlowSnapshotTreeDataManager.SNAPSHOT_FILE_PREFIX)
})
}
} else {
snapshotDataManager.clear()
}
}
}
private fun setUpBrowseButton() {
browseButton.icon = AllIcons.General.Ellipsis
browseButton.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent?) {
val chooser = JFileChooser()
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
val chooserValue = chooser.showOpenDialog(null)
if (chooserValue == JFileChooser.APPROVE_OPTION) {
var selectedFile = chooser.selectedFile
if (selectedFile.name != FlowTreeDataManager.FLOWS_DIRECTORY) {
val subDirectory = File(selectedFile, FlowTreeDataManager.FLOWS_DIRECTORY)
if (subDirectory.exists()) {
selectedFile = subDirectory
} else {
textField.text = "Could not find the ${FlowTreeDataManager.FLOWS_DIRECTORY} directory"
clear()
return
}
}
textField.text = selectedFile.canonicalPath
flowDataManager.loadFlows(selectedFile)
}
}
})
}
private fun setUpRefreshButton() {
refreshButton.icon = AllIcons.Actions.Refresh
refreshButton.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent?) {
clear()
flowDataManager.loadFlows()
}
})
}
private fun clear() {
flowTree.clearSelection()
flowDataManager.clear()
snapshotTree.clearSelection()
snapshotDataManager.clear()
}
private fun setUpTextField() {
textField.isEditable = false
textField.isFocusable = false
textField.background = Color(0, 0, 0, 0)
textField.text = "Choose flow data location (i.e. node base directory)"
}
private class FlowTreeRenderer : DefaultTreeCellRenderer() {
override fun getTreeCellRendererComponent(tree: JTree?, value: Any?, sel: Boolean, expanded: Boolean, leaf: Boolean, row: Int, hasFocus: Boolean): Component {
super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus)
val node = value as DefaultMutableTreeNode
if (node.isLeaf) {
icon = AllIcons.Debugger.Frame
}
val userObject = node.userObject
if (userObject is File) {
text = userObject.name
}
return this
}
}
private class SnapshotTreeRenderer : DefaultTreeCellRenderer() {
override fun getTreeCellRendererComponent(tree: JTree?, value: Any?, sel: Boolean, expanded: Boolean, leaf: Boolean, row: Int, hasFocus: Boolean): Component {
super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus)
val descriptor = (value as DefaultMutableTreeNode).userObject as SnapshotDataDescriptor
icon = descriptor.icon
if (!descriptor.key.isNullOrEmpty()) {
text = "${descriptor.key}: ${descriptor.data.toString()}"
}
return this
}
}
private class LeafOnlyTreeSelectionModel : DefaultTreeSelectionModel() {
override fun addSelectionPaths(paths: Array<out TreePath>?) {
super.addSelectionPaths(filterPaths(paths))
}
override fun setSelectionPaths(paths: Array<out TreePath>?) {
super.setSelectionPaths(filterPaths(paths))
}
private fun filterPaths(paths: Array<out TreePath>?): Array<TreePath>? {
return paths?.filter { (it.lastPathComponent as DefaultMutableTreeNode).isLeaf }?.toTypedArray()
}
}
}

View File

@ -0,0 +1,117 @@
package net.corda.ideaplugin.toolwindow
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.intellij.icons.AllIcons
import java.io.File
import javax.swing.Icon
import javax.swing.JTree
import javax.swing.tree.DefaultMutableTreeNode
import javax.swing.tree.DefaultTreeModel
import javax.swing.tree.MutableTreeNode
/**
* Snapshot tree data descriptor. It is used as userObject in the [DefaultMutableTreeNode] class.
*/
class SnapshotDataDescriptor(val data: Any?, val icon: Icon, val key: String? = null) {
override fun toString(): String = data?.toString() ?: "null"
}
/**
* Manager class for flow snapshots. It is responsible for parsing data read from a snapshot file and constructing
* tree model from it.
*/
class FlowSnapshotTreeDataManager(tree: JTree) {
companion object {
const val SNAPSHOT_FILE_PREFIX = "flowStackSnapshot"
}
// Root node for the snapshot hierarchy, which is an empty node.
private val root = DefaultMutableTreeNode(SnapshotDataDescriptor(null, AllIcons.Json.Object))
// Snapshot tree model
private val snapshotModel = DefaultTreeModel(root)
private val mapper = ObjectMapper()
init {
tree.model = snapshotModel
snapshotModel.reload()
}
/**
* Removes all current data from the snapshot hierarchy and refreshes the model.
*/
fun clear() {
root.removeAllChildren()
snapshotModel.reload()
}
/**
* Constructs tree model from snapshot files
*/
fun loadSnapshots(snapshots: List<File>) {
root.removeAllChildren()
snapshots.forEach {
insertNodeToSnapshotModel(it)
}
snapshotModel.reload()
}
/**
* Adds snapshot file to the snapshot hierarchy. The content of the file is processed and
* the model is updated accordingly.
*/
fun addNodeToSnapshotModel(snapshotFile: File) {
val insertionIndex = -(root.childNodes().map {
(it.userObject as SnapshotDataDescriptor).key
}.binarySearch(extractFileName(snapshotFile))) - 1
insertNodeToSnapshotModel(snapshotFile, insertionIndex)
}
/**
* Removes the snapshot file from the snapshot hierarchy. The model is also updated after this operation.
*/
fun removeNodeFromSnapshotModel(snapshotFile: File) {
val node = root.childNodes().find {
(it.userObject as SnapshotDataDescriptor).data == extractFileName(snapshotFile)
} as MutableTreeNode?
if (node != null) {
snapshotModel.removeNodeFromParent(node)
snapshotModel.nodesWereRemoved(root, intArrayOf(), arrayOf(node))
return
}
}
private fun insertNodeToSnapshotModel(snapshotFile: File, insertionIndex: Int = -1) {
buildChildrenModel(mapper.readTree(snapshotFile), root, extractFileName(snapshotFile), insertionIndex)
}
private fun extractFileName(file: File): String {
return file.name.substring(0, file.name.lastIndexOf("."))
}
private fun buildChildrenModel(
node: JsonNode?,
parent: DefaultMutableTreeNode, key: String? = null,
insertionIndex: Int = -1) {
val child: DefaultMutableTreeNode
if (node == null || !node.isContainerNode) {
child = DefaultMutableTreeNode(SnapshotDataDescriptor(node, AllIcons.Debugger.Db_primitive, key))
addToModelAndRefresh(snapshotModel, child, parent, insertionIndex)
} else {
if (node.isArray) {
child = DefaultMutableTreeNode(SnapshotDataDescriptor(key, AllIcons.Debugger.Db_array))
addToModelAndRefresh(snapshotModel, child, parent, insertionIndex)
node.mapIndexed { index: Int, item: JsonNode? ->
buildChildrenModel(item, child, index.toString())
}
} else {
child = DefaultMutableTreeNode(SnapshotDataDescriptor(key, AllIcons.Json.Object))
addToModelAndRefresh(snapshotModel, child, parent, insertionIndex)
node.fields().forEach {
buildChildrenModel(it.value, child, it.key)
}
}
}
}
}

View File

@ -0,0 +1,226 @@
package net.corda.ideaplugin.toolwindow
import java.io.File
import java.nio.file.*
import java.nio.file.StandardWatchEventKinds.ENTRY_CREATE
import java.nio.file.StandardWatchEventKinds.ENTRY_DELETE
import javax.swing.JTree
import javax.swing.SwingWorker
import javax.swing.tree.DefaultMutableTreeNode
import javax.swing.tree.DefaultTreeModel
/**
* Manager class for flow list data.
*/
class FlowTreeDataManager(val tree: JTree, val snapshotModel: FlowSnapshotTreeDataManager) {
companion object {
const val FLOWS_DIRECTORY = "flowStackSnapshots"
}
// Root node (i.e. [FLOWS_DIRECTORY]) of the flow directory hierarchy.
private val root = DefaultMutableTreeNode()
// Flow tree model
private val flowModel = DefaultTreeModel(root)
// Watcher service used to monitor directory contents addition and deletion.
private val watcher: WatchService = FileSystems.getDefault().newWatchService()
// Registered watch keys for the corresponding directories
private val watchKeys = hashMapOf<WatchKey, File>()
// Watch keys polling thread. Responsible for processing changes ot the contents of the monitored directories
private val dirObserver: DirObserver = DirObserver()
init {
tree.model = flowModel
flowModel.reload()
dirObserver.execute()
}
/**
* Builds the flow directory hierarchy with the root being associated with the passed [flowsDirectory].
* If the parameter is missing the function rebuilds current hierarchy and reloads (refreshes) current model.
*/
fun loadFlows(flowsDirectory: File? = root.userObjectAsFile()) {
root.userObject = flowsDirectory ?: return
root.removeAllChildren()
// Invalidate all current watch keys
invalidateWatchKeys()
// We need to add a watch key to the parent
startWatching(flowsDirectory)
// We expect 2-level directory nesting. The first level are dates and the second level are flow IDs.
// Dates directories
flowsDirectory.listFiles().filter { it.isDirectory }.forEach {
insertDateDirectory(it)
}
flowModel.reload()
}
/*
* Inserts date directory to the node hierarchy. A date directory is considered to be on the first hierarchy level.
*/
private fun insertDateDirectory(dateDir: File, insertionIndex: Int = -1) {
startWatching(dateDir)
val dateNode = DefaultMutableTreeNode(dateDir)
addToModelAndRefresh(flowModel, dateNode, root, insertionIndex)
// Flows directories
dateDir.listFiles().filter { it.isDirectory }.forEach {
startWatching(it)
addToModelAndRefresh(flowModel, DefaultMutableTreeNode(it), dateNode)
}
}
/**
* Removes current hierarchy.
*/
fun clear() {
invalidateWatchKeys()
root.removeAllChildren()
flowModel.reload()
}
private fun isSelected(dir: File?): Boolean {
val node = tree.selectedNode()
return node != null && dir == node.userObjectAsFile()
}
private fun startWatching(dir: File) {
val key = dir.toPath().register(watcher, ENTRY_CREATE, ENTRY_DELETE)
watchKeys.put(key, dir)
}
private fun invalidateWatchKeys() {
watchKeys.keys.forEach { it.cancel() }
watchKeys.clear()
}
private fun addNodeToFlowModel(dir: File) {
// We consider addition only of either a date or flow directory
val parent = dir.parentFile
if (parent.name == FLOWS_DIRECTORY) {
// if a date directory has been added this means that that its parent is [FLOWS_DIRECTORY]
insertDateDirectory(dir, findInsertionIndex(root.childNodes(), dir.name))
} else if (parent.parentFile.name == FLOWS_DIRECTORY) {
// if a flow directory has been added this means that that the parent of its parent is [FLOWS_DIRECTORY]
val parentNode = root.childNodes().findByFile(parent) ?: return
flowModel.insertNodeInto(DefaultMutableTreeNode(dir), parentNode, findInsertionIndex(parentNode.childNodes(), dir.name))
startWatching(dir)
}
}
private fun removeNodeFromFlowModel(dir: File) {
val selectedNode = tree.selectedNode()
if (selectedNode != null && selectedNode.userObjectAsFile() == dir) {
// Reload flows if the [dir] is currently selected
loadFlows()
return
}
val parent = dir.parentFile
var parentNode: DefaultMutableTreeNode? = null
if (parent.name == FLOWS_DIRECTORY) {
// if a date directory has been added this means that that its parent is [FLOWS_DIRECTORY]
parentNode = root
} else if (parent.parentFile.name == FLOWS_DIRECTORY) {
// if a flow directory has been added this means that that the parent of its parent is [FLOWS_DIRECTORY]
parentNode = root.childNodes().findByFile(parent)
}
if (parentNode != null) {
val node = parentNode.childNodes().findByFile(dir) ?: return
flowModel.removeNodeFromParent(node)
}
}
private fun findInsertionIndex(nodes: List<DefaultMutableTreeNode>, name: String): Int {
return -nodes.toList().map { (it.userObject as File).name }.binarySearch(name) - 1
}
/*
* Swing thread polling the watcher service for any changes (i.e. entry creation or deletion).
* Once a change is detected the event is processed accordingly.
*/
private inner class DirObserver : SwingWorker<Void, WatchKey>() {
override fun doInBackground(): Void? {
while (true) {
// wait for key to be signaled
val key = try {
watcher.take()
} catch (x: InterruptedException) {
return null
}
publish(key)
if (!key.reset()) {
watchKeys.remove(key)
}
}
}
override fun process(keys: MutableList<WatchKey>?) {
keys?.forEach {
processKey(it)
}
}
private fun processKey(key: WatchKey) {
key.pollEvents().forEach {
val kind = it.kind()
// The OVERFLOW event can occur regardless if events are lost or discarded.
if (kind == StandardWatchEventKinds.OVERFLOW) {
return
}
processEvent(it as WatchEvent<*>, key)
}
}
private fun processEvent(event: WatchEvent<*>, key: WatchKey) {
val parent = getParent(key)
val file = File(parent, (event.context() as Path).toString())
when (event.kind()) {
ENTRY_CREATE -> {
if (!file.isDirectory) {
// We only need to do anything if the flow snapshots are being currently examined
if (isSelected(parent) && file.name.startsWith(FlowSnapshotTreeDataManager.SNAPSHOT_FILE_PREFIX)) {
snapshotModel.addNodeToSnapshotModel(file)
}
} else {
addNodeToFlowModel(file)
}
}
ENTRY_DELETE -> {
// We only need to do anything if the flow snapshots are being currently examined
if (isSelected(parent) && file.name.startsWith(FlowSnapshotTreeDataManager.SNAPSHOT_FILE_PREFIX)) {
snapshotModel.removeNodeFromSnapshotModel(file)
} else {
removeNodeFromFlowModel(file)
}
}
}
}
private fun getParent(key: WatchKey): File? {
return watchKeys[key]
}
}
}
internal fun DefaultMutableTreeNode.userObjectAsFile() = userObject as? File
internal fun DefaultMutableTreeNode.childNodes() = children().toList().mapNotNull { it as? DefaultMutableTreeNode }
private fun List<DefaultMutableTreeNode>.findByFile(file: File) = find { it.userObjectAsFile() == file }
private fun JTree.selectedNode() = lastSelectedPathComponent as? DefaultMutableTreeNode
internal fun addToModelAndRefresh(model: DefaultTreeModel,
child: DefaultMutableTreeNode,
parent: DefaultMutableTreeNode,
insertionIndex: Int = -1) {
val indices = if (insertionIndex < 0) {
parent.add(child)
intArrayOf(parent.childCount - 1)
} else {
parent.insert(child, insertionIndex)
intArrayOf(insertionIndex)
}
model.nodesWereInserted(parent, indices)
}

View File

@ -26,7 +26,8 @@
<depends>org.jetbrains.plugins.gradle</depends>
<extensions defaultExtensionNs="com.intellij">
<moduleType id="CORDA_MODULE" implementationClass="net.corda.ideaPlugin.module.CordaModuleType"/>
<moduleType id="CORDA_MODULE" implementationClass="net.corda.ideaplugin.module.CordaModuleType"/>
<toolWindow id="Corda Flow Tool" icon="/images/corda-toolwindow-icon.png" anchor="right" factoryClass="net.corda.ideaplugin.toolwindow.CordaFlowToolWindow" />
</extensions>
<actions>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 B