From efe13712ad30cd5bcea2fb1632b5a4d59fa75eb7 Mon Sep 17 00:00:00 2001
From: chylex <contact@chylex.com>
Date: Wed, 18 Sep 2024 19:39:48 +0200
Subject: [PATCH] Add new vim-style bindings for navigating tree hierarchy

---
 .../components/VimTreeNavigation.kt           | 107 ++++++++++++++++--
 1 file changed, 95 insertions(+), 12 deletions(-)

diff --git a/src/main/kotlin/com/chylex/intellij/keyboardmaster/feature/vimNavigation/components/VimTreeNavigation.kt b/src/main/kotlin/com/chylex/intellij/keyboardmaster/feature/vimNavigation/components/VimTreeNavigation.kt
index d952159..8869674 100644
--- a/src/main/kotlin/com/chylex/intellij/keyboardmaster/feature/vimNavigation/components/VimTreeNavigation.kt
+++ b/src/main/kotlin/com/chylex/intellij/keyboardmaster/feature/vimNavigation/components/VimTreeNavigation.kt
@@ -25,16 +25,20 @@ internal object VimTreeNavigation {
 					KeyStroke.getKeyStroke('g') to IdeaAction("Tree-selectFirst"),
 					KeyStroke.getKeyStroke('j') to SelectLastSibling,
 					KeyStroke.getKeyStroke('k') to SelectFirstSibling,
-					KeyStroke.getKeyStroke('o') to ExpandTreeNodeChildrenToNextLevel,
+					KeyStroke.getKeyStroke('o') to ExpandChildrenToNextLevel,
 				)
 			),
 			KeyStroke.getKeyStroke('G') to IdeaAction("Tree-selectLast"),
+			KeyStroke.getKeyStroke('h') to CollapseSelfOrMoveToParentNode,
+			KeyStroke.getKeyStroke('H') to CollapseUntilRootNode,
 			KeyStroke.getKeyStroke('j') to IdeaAction("Tree-selectNext"),
 			KeyStroke.getKeyStroke('j', KeyEvent.ALT_DOWN_MASK) to IdeaAction("Tree-selectNextSibling"),
 			KeyStroke.getKeyStroke('J') to IdeaAction("Tree-selectNextExtendSelection"),
 			KeyStroke.getKeyStroke('k') to IdeaAction("Tree-selectPrevious"),
 			KeyStroke.getKeyStroke('k', KeyEvent.ALT_DOWN_MASK) to IdeaAction("Tree-selectPreviousSibling"),
 			KeyStroke.getKeyStroke('K') to IdeaAction("Tree-selectPreviousExtendSelection"),
+			KeyStroke.getKeyStroke('l') to ExpandSelfOrMoveToFirstChildNode,
+			KeyStroke.getKeyStroke('L') to ExpandUntilFirstLeafNode,
 			KeyStroke.getKeyStroke('o') to ExpandOrCollapseTreeNode,
 			KeyStroke.getKeyStroke('O') to IdeaAction("FullyExpandTreeNode"),
 			KeyStroke.getKeyStroke('p') to IdeaAction("Tree-selectParentNoCollapse"),
@@ -64,6 +68,50 @@ internal object VimTreeNavigation {
 		}
 	}
 	
+	private data object ExpandSelfOrMoveToFirstChildNode : ActionNode<VimNavigationDispatcher<JTree>> {
+		override fun performAction(holder: VimNavigationDispatcher<JTree>, actionEvent: AnActionEvent, keyEvent: KeyEvent) {
+			val tree = holder.component
+			val path = tree.selectionPath?.takeUnless { isLeaf(tree, it) } ?: return
+			
+			if (tree.isExpanded(path)) {
+				selectRow(tree, getFirstChild(tree, path))
+			}
+			else {
+				runWithoutAutoExpand(tree) { tree.expandPath(path) }
+			}
+		}
+	}
+	
+	private data object ExpandUntilFirstLeafNode : ActionNode<VimNavigationDispatcher<JTree>> {
+		override fun performAction(holder: VimNavigationDispatcher<JTree>, actionEvent: AnActionEvent, keyEvent: KeyEvent) {
+			val tree = holder.component
+			val path = tree.selectionPath ?: return
+			
+			var firstChildPath = path
+			
+			while (!isLeaf(tree, firstChildPath)) {
+				tree.expandPath(firstChildPath)
+				firstChildPath = getFirstChild(tree, firstChildPath)
+			}
+			
+			selectRow(tree, firstChildPath)
+		}
+	}
+	
+	private data object CollapseSelfOrMoveToParentNode : ActionNode<VimNavigationDispatcher<JTree>> {
+		override fun performAction(holder: VimNavigationDispatcher<JTree>, actionEvent: AnActionEvent, keyEvent: KeyEvent) {
+			val tree = holder.component
+			val path = tree.selectionPath ?: return
+			
+			if (tree.isExpanded(path)) {
+				collapseAndScroll(tree, path)
+			}
+			else {
+				withParentPath(tree, path) { selectRow(tree, it) }
+			}
+		}
+	}
+	
 	private data object CollapseSelfOrParentNode : ActionNode<VimNavigationDispatcher<JTree>> {
 		override fun performAction(holder: VimNavigationDispatcher<JTree>, actionEvent: AnActionEvent, keyEvent: KeyEvent) {
 			val tree = holder.component
@@ -73,24 +121,31 @@ internal object VimTreeNavigation {
 				collapseAndScroll(tree, path)
 			}
 			else {
-				val parentPath = path.parentPath
-				if (parentPath.parentPath != null || tree.isRootVisible) {
-					collapseAndScroll(tree, parentPath)
-				}
+				withParentPath(tree, path) { collapseAndScroll(tree, it) }
 			}
 		}
-		
-		private fun collapseAndScroll(tree: JTree, path: TreePath) {
-			tree.collapsePath(path)
-			tree.scrollRowToVisible(tree.getRowForPath(path))
-		}
 	}
 	
-	private data object ExpandTreeNodeChildrenToNextLevel : ActionNode<VimNavigationDispatcher<JTree>> {
+	private data object CollapseUntilRootNode : ActionNode<VimNavigationDispatcher<JTree>> {
+		override fun performAction(holder: VimNavigationDispatcher<JTree>, actionEvent: AnActionEvent, keyEvent: KeyEvent) {
+			val tree = holder.component
+			val path = tree.selectionPath ?: return
+			
+			var parentPath = path
+			
+			while (true) {
+				parentPath = parentPath.parentPath.takeUnless { isInvisibleRoot(tree, it) } ?: break
+			}
+			
+			collapseAndScroll(tree, parentPath)
+		}
+	}
+	
+	private data object ExpandChildrenToNextLevel : ActionNode<VimNavigationDispatcher<JTree>> {
 		override fun performAction(holder: VimNavigationDispatcher<JTree>, actionEvent: AnActionEvent, keyEvent: KeyEvent) {
 			val tree = holder.component
 			val model = tree.model
-			val path = tree.selectionPath?.takeUnless { model.isLeaf(it.lastPathComponent) } ?: return
+			val path = tree.selectionPath?.takeUnless { isLeaf(tree, it) } ?: return
 			
 			var pathsToExpand = mutableListOf(path)
 			
@@ -170,4 +225,32 @@ internal object VimTreeNavigation {
 		tree.setSelectionRow(row)
 		tree.scrollRowToVisible(row)
 	}
+	
+	private fun selectRow(tree: JTree, path: TreePath) {
+		selectRow(tree, tree.getRowForPath(path))
+	}
+	
+	private fun collapseAndScroll(tree: JTree, path: TreePath) {
+		tree.collapsePath(path)
+		tree.scrollRowToVisible(tree.getRowForPath(path))
+	}
+	
+	private inline fun withParentPath(tree: JTree, path: TreePath, action: (TreePath) -> Unit) {
+		val parentPath = path.parentPath
+		if (!isInvisibleRoot(tree, parentPath)) {
+			action(parentPath)
+		}
+	}
+	
+	private fun isInvisibleRoot(tree: JTree, parentPath: TreePath): Boolean {
+		return parentPath.parentPath == null && !tree.isRootVisible
+	}
+	
+	private fun getFirstChild(tree: JTree, path: TreePath): TreePath {
+		return path.pathByAddingChild(tree.model.getChild(path.lastPathComponent, 0))
+	}
+	
+	private fun isLeaf(tree: JTree, firstChildPath: TreePath): Boolean {
+		return tree.model.isLeaf(firstChildPath.lastPathComponent)
+	}
 }