Add support for clicking on arrows in the file list to expand/collapse directories (#5365)

### PR Description
This PR adds support for clicking on arrows in the file changes list
view, so that a mouse can be used to expand/collapse directories. This
enhancement was raised here as issue #5088.
This commit is contained in:
Stefan Haller 2026-03-25 20:58:49 +01:00 committed by GitHub
commit d864edb0c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 358 additions and 24 deletions

View file

@ -15,7 +15,8 @@ type BaseContext struct {
keybindingsFns []types.KeybindingsFn
mouseKeybindingsFns []types.MouseKeybindingsFn
onClickFn func() error
onDoubleClickFn func() error
onClickFn func(opts gocui.ViewMouseBindingOpts) error
onClickFocusedMainViewFn onClickFocusedMainViewFn
onRenderToMainFn func()
onFocusFns []onFocusFn
@ -140,12 +141,22 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() {
self.mouseKeybindingsFns = nil
self.onFocusFns = nil
self.onFocusLostFns = nil
self.onDoubleClickFn = nil
self.onClickFn = nil
self.onClickFocusedMainViewFn = nil
self.onRenderToMainFn = nil
}
func (self *BaseContext) AddOnClickFn(fn func() error) {
func (self *BaseContext) AddOnDoubleClickFn(fn func() error) {
if fn != nil {
if self.onDoubleClickFn != nil {
panic("only one controller is allowed to set an onDoubleClickFn")
}
self.onDoubleClickFn = fn
}
}
func (self *BaseContext) AddOnClickFn(fn func(opts gocui.ViewMouseBindingOpts) error) {
if fn != nil {
if self.onClickFn != nil {
panic("only one controller is allowed to set an onClickFn")
@ -163,7 +174,11 @@ func (self *BaseContext) AddOnClickFocusedMainViewFn(fn onClickFocusedMainViewFn
}
}
func (self *BaseContext) GetOnClick() func() error {
func (self *BaseContext) GetOnDoubleClick() func() error {
return self.onDoubleClickFn
}
func (self *BaseContext) GetOnClick() func(opts gocui.ViewMouseBindingOpts) error {
return self.onClickFn
}

View file

@ -89,6 +89,6 @@ func (self *SuggestionsContext) RangeSelectEnabled() bool {
return false
}
func (self *SuggestionsContext) GetOnClick() func() error {
func (self *SuggestionsContext) GetOnDoubleClick() func() error {
return self.State.OnConfirm
}

View file

@ -6,6 +6,7 @@ func AttachControllers(context types.Context, controllers ...types.IController)
for _, controller := range controllers {
context.AddKeybindingsFn(controller.GetKeybindings)
context.AddMouseKeybindingsFn(controller.GetMouseKeybindings)
context.AddOnDoubleClickFn(controller.GetOnDoubleClick())
context.AddOnClickFn(controller.GetOnClick())
context.AddOnClickFocusedMainViewFn(controller.GetOnClickFocusedMainView())
context.AddOnRenderToMainFn(controller.GetOnRenderToMain())

View file

@ -15,7 +15,7 @@ func (self *baseController) GetMouseKeybindings(opts types.KeybindingsOpts) []*g
return nil
}
func (self *baseController) GetOnClick() func() error {
func (self *baseController) GetOnDoubleClick() func() error {
return nil
}
@ -23,6 +23,10 @@ func (self *baseController) GetOnClickFocusedMainView() func(mainViewName string
return nil
}
func (self *baseController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) error {
return nil
}
func (self *baseController) GetOnRenderToMain() func() {
return nil
}

View file

@ -142,6 +142,30 @@ func (self *CommitFilesController) context() *context.CommitFilesContext {
return self.c.Contexts().CommitFiles
}
func (self *CommitFilesController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) error {
return func(opts gocui.ViewMouseBindingOpts) error {
clickedIdx := self.context().GetSelectedLineIdx()
node := self.context().CommitFileTreeViewModel.Get(clickedIdx)
if node == nil || node.File != nil {
return nil
}
// The arrow is at column visualDepth*2 (after indentation of 2 spaces per level).
// Only treat clicks on the arrow and the trailing space as arrow clicks.
visualDepth := self.context().CommitFileTreeViewModel.GetVisualDepth(clickedIdx)
arrowStartCol := visualDepth * 2
arrowEndCol := arrowStartCol + 1
if opts.X < arrowStartCol || opts.X > arrowEndCol {
return nil
}
self.context().CommitFileTreeViewModel.ToggleCollapsed(node.GetInternalPath())
self.c.PostRefreshUpdate(self.context())
return nil
}
}
func (self *CommitFilesController) GetOnRenderToMain() func() {
return func() {
node := self.context().GetSelected()

View file

@ -229,6 +229,30 @@ func (self *FilesController) GetMouseKeybindings(opts types.KeybindingsOpts) []*
}
}
func (self *FilesController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) error {
return func(opts gocui.ViewMouseBindingOpts) error {
clickedIdx := self.context().GetSelectedLineIdx()
node := self.context().FileTreeViewModel.Get(clickedIdx)
if node == nil || node.File != nil {
return nil
}
// The arrow is at column visualDepth*2 (after indentation of 2 spaces per level).
// Only treat clicks on the arrow and the trailing space as arrow clicks.
visualDepth := self.context().FileTreeViewModel.GetVisualDepth(clickedIdx)
arrowStartCol := visualDepth * 2
arrowEndCol := arrowStartCol + 1
if opts.X < arrowStartCol || opts.X > arrowEndCol {
return nil
}
self.context().FileTreeViewModel.ToggleCollapsed(node.GetInternalPath())
self.c.PostRefreshUpdate(self.context())
return nil
}
}
func (self *FilesController) GetOnRenderToMain() func() {
return func() {
self.c.Helpers().Diff.WithDiffModeCheck(func() {
@ -329,7 +353,7 @@ func (self *FilesController) GetOnRenderToMain() func() {
}
}
func (self *FilesController) GetOnClick() func() error {
func (self *FilesController) GetOnDoubleClick() func() error {
return self.withItemGraceful(func(node *filetree.FileNode) error {
return self.press([]*filetree.FileNode{node})
})

View file

@ -243,10 +243,17 @@ func (self *ListController) HandleClick(opts gocui.ViewMouseBindingOpts) error {
self.context.GetList().SetSelection(newSelectedLineIdx)
if opts.IsDoubleClick && alreadyFocused && self.context.GetOnClick() != nil {
return self.context.GetOnClick()()
if opts.IsDoubleClick && alreadyFocused && self.context.GetOnDoubleClick() != nil {
return self.context.GetOnDoubleClick()()
}
self.context.HandleFocus(types.OnFocusOpts{})
// Let view-specific controllers do additional click handling
if self.context.GetOnClick() != nil {
return self.context.GetOnClick()(opts)
}
return nil
}

View file

@ -55,7 +55,7 @@ func (self *MenuController) GetKeybindings(opts types.KeybindingsOpts) []*types.
return bindings
}
func (self *MenuController) GetOnClick() func() error {
func (self *MenuController) GetOnDoubleClick() func() error {
return self.withItemGraceful(self.press)
}

View file

@ -120,7 +120,7 @@ func (self *RemotesController) GetOnRenderToMain() func() {
}
}
func (self *RemotesController) GetOnClick() func() error {
func (self *RemotesController) GetOnDoubleClick() func() error {
return self.withItemGraceful(self.enter)
}

View file

@ -102,7 +102,7 @@ func (self *SubmodulesController) GetKeybindings(opts types.KeybindingsOpts) []*
}
}
func (self *SubmodulesController) GetOnClick() func() error {
func (self *SubmodulesController) GetOnDoubleClick() func() error {
return self.withItemGraceful(self.enter)
}

View file

@ -54,7 +54,7 @@ func (self *SwitchToDiffFilesController) Context() types.Context {
return self.context
}
func (self *SwitchToDiffFilesController) GetOnClick() func() error {
func (self *SwitchToDiffFilesController) GetOnDoubleClick() func() error {
return func() error {
if self.canEnter() == nil {
return self.enter()

View file

@ -55,7 +55,7 @@ func (self *SwitchToSubCommitsController) GetKeybindings(opts types.KeybindingsO
return bindings
}
func (self *SwitchToSubCommitsController) GetOnClick() func() error {
func (self *SwitchToSubCommitsController) GetOnDoubleClick() func() error {
return self.viewCommits
}

View file

@ -133,7 +133,7 @@ func (self *WorktreesController) remove(worktree *models.Worktree) error {
return self.c.Helpers().Worktree.Remove(worktree, false)
}
func (self *WorktreesController) GetOnClick() func() error {
func (self *WorktreesController) GetOnDoubleClick() func() error {
return self.withItemGraceful(self.enter)
}

View file

@ -151,6 +151,10 @@ func (self *CommitFileTree) GetFile(path string) *models.CommitFile {
return nil
}
func (self *CommitFileTree) GetVisualDepth(index int) int {
return self.tree.GetVisualDepthAtIndex(index+1, self.collapsedPaths) // +1 to skip root
}
func (self *CommitFileTree) InTreeMode() bool {
return self.showTree
}

View file

@ -34,6 +34,7 @@ type ITree[T any] interface {
CollapsedPaths() *CollapsedPaths
CollapseAll()
ExpandAll()
GetVisualDepth(index int) int
}
type IFileTree interface {
@ -221,6 +222,10 @@ func (self *FileTree) CollapsedPaths() *CollapsedPaths {
return self.collapsedPaths
}
func (self *FileTree) GetVisualDepth(index int) int {
return self.tree.GetVisualDepthAtIndex(index+1, self.collapsedPaths) // +1 to skip root
}
func (self *FileTree) GetStatusFilter() FileTreeDisplayFilter {
return self.filter
}

View file

@ -202,29 +202,43 @@ func (self *Node[T]) GetNodeAtIndex(index int, collapsedPaths *CollapsedPaths) *
return nil
}
node, _ := self.getNodeAtIndexAux(index, collapsedPaths)
node, _, _ := self.getNodeAtIndexAux(index, collapsedPaths, -1)
return node
}
func (self *Node[T]) getNodeAtIndexAux(index int, collapsedPaths *CollapsedPaths) (*Node[T], int) {
// GetVisualDepthAtIndex returns the visual depth (indentation level) of the
// node at the given flat index. Visual depth differs from tree depth because
// compressed nodes (e.g. "a/b/") count as a single visual level.
// Returns -1 if the index is out of range.
func (self *Node[T]) GetVisualDepthAtIndex(index int, collapsedPaths *CollapsedPaths) int {
if self == nil {
return -1
}
_, _, depth := self.getNodeAtIndexAux(index, collapsedPaths, -1)
return depth
}
func (self *Node[T]) getNodeAtIndexAux(index int, collapsedPaths *CollapsedPaths, visualDepth int) (*Node[T], int, int) {
offset := 1
if index == 0 {
return self, offset
return self, offset, visualDepth
}
if !collapsedPaths.IsCollapsed(self.path) {
for _, child := range self.Children {
foundNode, offsetChange := child.getNodeAtIndexAux(index-offset, collapsedPaths)
foundNode, offsetChange, depth := child.getNodeAtIndexAux(index-offset, collapsedPaths, visualDepth+1)
offset += offsetChange
if foundNode != nil {
return foundNode, offset
return foundNode, offset, depth
}
}
}
return nil, offset
return nil, offset, -1
}
func (self *Node[T]) GetIndexForPath(path string, collapsedPaths *CollapsedPaths) (int, bool) {

View file

@ -0,0 +1,134 @@
package filetree
import (
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/stretchr/testify/assert"
)
func TestGetVisualDepthAtIndex(t *testing.T) {
scenarios := []struct {
name string
files []*models.File
showRootItem bool
collapsedPaths []string
expectedDepths []int // one per visible node, skipping root
}{
{
name: "flat files with root item",
files: []*models.File{
{Path: "a"},
{Path: "b"},
},
showRootItem: true,
// Displayed as:
// index 0: ▼ / (depth 0, the "." root dir)
// index 1: a (depth 1)
// index 2: b (depth 1)
expectedDepths: []int{0, 1, 1},
},
{
name: "flat files without root item",
files: []*models.File{
{Path: "a"},
{Path: "b"},
},
showRootItem: false,
// Displayed as:
// index 0: a (depth 0)
// index 1: b (depth 0)
expectedDepths: []int{0, 0},
},
{
name: "nested directories with root item",
files: []*models.File{
{Path: "dir/a"},
{Path: "dir/b"},
{Path: "c"},
},
showRootItem: true,
// Displayed as:
// index 0: ▼ / (depth 0)
// index 1: ▼ dir (depth 1)
// index 2: a (depth 2)
// index 3: b (depth 2)
// index 4: c (depth 1)
expectedDepths: []int{0, 1, 2, 2, 1},
},
{
name: "compressed paths with root item",
files: []*models.File{
{Path: "dir1/dir3/a"},
{Path: "dir2/dir4/b"},
},
showRootItem: true,
// Tree compresses dir1/dir3 and dir2/dir4 into single nodes.
// Displayed as:
// index 0: ▼ / (depth 0)
// index 1: ▼ dir1/dir3 (depth 1, compressed)
// index 2: a (depth 2)
// index 3: ▼ dir2/dir4 (depth 1, compressed)
// index 4: b (depth 2)
expectedDepths: []int{0, 1, 2, 1, 2},
},
{
name: "compressed paths without root item",
files: []*models.File{
{Path: "dir1/dir3/a"},
{Path: "dir2/dir4/b"},
},
showRootItem: false,
// Displayed as:
// index 0: ▼ dir1/dir3 (depth 0, compressed)
// index 1: a (depth 1)
// index 2: ▼ dir2/dir4 (depth 0, compressed)
// index 3: b (depth 1)
expectedDepths: []int{0, 1, 0, 1},
},
{
name: "collapsed directory hides children",
files: []*models.File{
{Path: "dir/a"},
{Path: "dir/b"},
{Path: "c"},
},
showRootItem: true,
collapsedPaths: []string{"./dir"},
// Displayed as:
// index 0: ▼ / (depth 0)
// index 1: ▶ dir (depth 1, collapsed)
// index 2: c (depth 1)
expectedDepths: []int{0, 1, 1},
},
{
name: "out of range returns -1",
files: []*models.File{
{Path: "a"},
},
showRootItem: false,
expectedDepths: []int{0},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
tree := BuildTreeFromFiles(s.files, s.showRootItem)
collapsedPaths := NewCollapsedPaths()
for _, p := range s.collapsedPaths {
collapsedPaths.Collapse(p)
}
for i, expectedDepth := range s.expectedDepths {
// +1 to skip the invisible root node, matching what FileTree.GetVisualDepth does
actualDepth := tree.GetVisualDepthAtIndex(i+1, collapsedPaths)
assert.Equal(t, expectedDepth, actualDepth,
"index %d: expected depth %d, got %d", i, expectedDepth, actualDepth)
}
// Verify out-of-range returns -1
outOfRange := tree.GetVisualDepthAtIndex(len(s.expectedDepths)+1, collapsedPaths)
assert.Equal(t, -1, outOfRange, "out of range index should return -1")
})
}
}

View file

@ -91,13 +91,16 @@ type IBaseContext interface {
AddMouseKeybindingsFn(MouseKeybindingsFn)
ClearAllAttachedControllerFunctions()
// This is a bit of a hack at the moment: we currently only set an onclick function so that
// our list controller can come along and wrap it in a list-specific click handler.
// This is a bit of a hack at the moment: we currently only set an onDoubleClick function so
// that the generic ListController can be specialized by view-specific controllers.
// We'll need to think of a better way to do this.
AddOnClickFn(func() error)
AddOnDoubleClickFn(func() error)
// Likewise for the focused main view: we need this to communicate between a
// side panel controller and the focused main view controller.
AddOnClickFocusedMainViewFn(func(mainViewName string, clickedLineIdx int) error)
// Adding on to the above, this is so that a list-specific handler can register
// a hook for doing additional click handling
AddOnClickFn(func(opts gocui.ViewMouseBindingOpts) error)
AddOnRenderToMainFn(func())
AddOnFocusFn(func(OnFocusOpts))
@ -246,7 +249,19 @@ type (
type HasKeybindings interface {
GetKeybindings(opts KeybindingsOpts) []*Binding
GetMouseKeybindings(opts KeybindingsOpts) []*gocui.ViewMouseBinding
GetOnClick() func() error
// Implement this to get called when there's a double-click on the view. Only supported by list
// views currently. Will be called after the double-clicked list entry has been selected.
GetOnDoubleClick() func() error
// Implement this to get called for any non-double-click in the view. Only supported by list
// views currently. Will be called after the clicked list entry has been selected, and
// HandleFocus has already been called (so the main view is up to date). Should return nil if it
// decides not to do anything with the click.
GetOnClick() func(opts gocui.ViewMouseBindingOpts) error
// Implement this in a side-panel controller to get called when there's a click in the main view
// that belongs to your panel while the main view is already focused.
GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error
}

View file

@ -0,0 +1,86 @@
package file
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ClickArrowToCollapse = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Click the arrow on a directory to collapse/expand it",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateDir("dir")
shell.CreateFile("dir/file-one", "original content\n")
shell.CreateDir("dir2")
shell.CreateFile("dir2/file-two", "original content\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Lines(
Equals("▼ /").IsSelected(),
Equals(" ▼ dir"),
Equals(" ?? file-one"),
Equals(" ▼ dir2"),
Equals(" ?? file-two"),
)
// Click the arrow on "dir" (row 1, column 2) to collapse it
t.Views().Files().
Click(2, 1).
Lines(
Equals("▼ /"),
Equals(" ▶ dir").IsSelected(),
Equals(" ▼ dir2"),
Equals(" ?? file-two"),
)
// Click one to the right of the arrow on "dir2" (row 2, column 3) to collapse it
// Arrow + space after should register a collapse toggle
t.Views().Files().
Click(3, 2).
Lines(
Equals("▼ /"),
Equals(" ▶ dir"),
Equals(" ▶ dir2").IsSelected(),
)
// Click one to the left of the arrow on "dir2" (row 2, column 1)
// Space before arrow should not register a collapse toggle
t.Views().Files().
Click(1, 2).
Lines(
Equals("▼ /"),
Equals(" ▶ dir"),
Equals(" ▶ dir2").IsSelected(),
)
// Clicking on the file/directory name "dir" should change selected but not toggle collapse
t.Views().Files().
Click(5, 1).
Lines(
Equals("▼ /"),
Equals(" ▶ dir").IsSelected(),
Equals(" ▶ dir2"),
)
// Click the arrow again to expand it
t.Views().Files().
Click(2, 1).
Lines(
Equals("▼ /"),
Equals(" ▼ dir").IsSelected(),
Equals(" ?? file-one"),
Equals(" ▶ dir2"),
)
// Click the arrow on the root "/" (row 0, column 0) to collapse everything
t.Views().Files().
Click(0, 0).
Lines(
Equals("▶ /").IsSelected(),
)
},
})

View file

@ -210,6 +210,7 @@ var tests = []*components.IntegrationTest{
diff.DiffNonStickyRange,
diff.IgnoreWhitespace,
diff.RenameSimilarityThresholdChange,
file.ClickArrowToCollapse,
file.CollapseExpand,
file.CopyMenu,
file.DirWithUntrackedFile,