From 72bff90822570f733f7abbb1edadf48b50e9a026 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 25 Mar 2026 14:32:22 +0100 Subject: [PATCH 1/5] Rename GetOnClick (et al) to GetOnDoubleClick When this was originally introduced, it handled single clicks on a list entry (treating them similar to a double-click by checking whether the click was on the selected entry). Arguably it should have been called OnDoubleClick back then already; but when we later changed it to do actual double-click detection (see 37197b8e9a3eab), we should have renamed the methods. --- pkg/gui/context/base_context.go | 16 ++++++++-------- pkg/gui/context/suggestions_context.go | 2 +- pkg/gui/controllers/attach.go | 2 +- pkg/gui/controllers/base_controller.go | 2 +- pkg/gui/controllers/files_controller.go | 2 +- pkg/gui/controllers/list_controller.go | 4 ++-- pkg/gui/controllers/menu_controller.go | 2 +- pkg/gui/controllers/remotes_controller.go | 2 +- pkg/gui/controllers/submodules_controller.go | 2 +- .../switch_to_diff_files_controller.go | 2 +- .../switch_to_sub_commits_controller.go | 2 +- pkg/gui/controllers/worktrees_controller.go | 2 +- pkg/gui/types/context.go | 8 ++++---- 13 files changed, 24 insertions(+), 24 deletions(-) diff --git a/pkg/gui/context/base_context.go b/pkg/gui/context/base_context.go index 7c6e9b617..a21e95e3f 100644 --- a/pkg/gui/context/base_context.go +++ b/pkg/gui/context/base_context.go @@ -15,7 +15,7 @@ type BaseContext struct { keybindingsFns []types.KeybindingsFn mouseKeybindingsFns []types.MouseKeybindingsFn - onClickFn func() error + onDoubleClickFn func() error onClickFocusedMainViewFn onClickFocusedMainViewFn onRenderToMainFn func() onFocusFns []onFocusFn @@ -140,17 +140,17 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() { self.mouseKeybindingsFns = nil self.onFocusFns = nil self.onFocusLostFns = nil - self.onClickFn = nil + self.onDoubleClickFn = 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.onClickFn != nil { - panic("only one controller is allowed to set an onClickFn") + if self.onDoubleClickFn != nil { + panic("only one controller is allowed to set an onDoubleClickFn") } - self.onClickFn = fn + self.onDoubleClickFn = fn } } @@ -163,8 +163,8 @@ func (self *BaseContext) AddOnClickFocusedMainViewFn(fn onClickFocusedMainViewFn } } -func (self *BaseContext) GetOnClick() func() error { - return self.onClickFn +func (self *BaseContext) GetOnDoubleClick() func() error { + return self.onDoubleClickFn } func (self *BaseContext) GetOnClickFocusedMainView() onClickFocusedMainViewFn { diff --git a/pkg/gui/context/suggestions_context.go b/pkg/gui/context/suggestions_context.go index 97d28ffc7..eafe7fb7c 100644 --- a/pkg/gui/context/suggestions_context.go +++ b/pkg/gui/context/suggestions_context.go @@ -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 } diff --git a/pkg/gui/controllers/attach.go b/pkg/gui/controllers/attach.go index 8c1dbb91e..b703c28c4 100644 --- a/pkg/gui/controllers/attach.go +++ b/pkg/gui/controllers/attach.go @@ -6,7 +6,7 @@ func AttachControllers(context types.Context, controllers ...types.IController) for _, controller := range controllers { context.AddKeybindingsFn(controller.GetKeybindings) context.AddMouseKeybindingsFn(controller.GetMouseKeybindings) - context.AddOnClickFn(controller.GetOnClick()) + context.AddOnDoubleClickFn(controller.GetOnDoubleClick()) context.AddOnClickFocusedMainViewFn(controller.GetOnClickFocusedMainView()) context.AddOnRenderToMainFn(controller.GetOnRenderToMain()) context.AddOnFocusFn(controller.GetOnFocus()) diff --git a/pkg/gui/controllers/base_controller.go b/pkg/gui/controllers/base_controller.go index 0e1229222..d85f7f052 100644 --- a/pkg/gui/controllers/base_controller.go +++ b/pkg/gui/controllers/base_controller.go @@ -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 } diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 8cc2ca5e2..557b40e6a 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -329,7 +329,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}) }) diff --git a/pkg/gui/controllers/list_controller.go b/pkg/gui/controllers/list_controller.go index 6da196a60..f07c5994d 100644 --- a/pkg/gui/controllers/list_controller.go +++ b/pkg/gui/controllers/list_controller.go @@ -243,8 +243,8 @@ 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{}) return nil diff --git a/pkg/gui/controllers/menu_controller.go b/pkg/gui/controllers/menu_controller.go index 0465308df..2f145508f 100644 --- a/pkg/gui/controllers/menu_controller.go +++ b/pkg/gui/controllers/menu_controller.go @@ -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) } diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index e1f08bc7d..8d47d1721 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -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) } diff --git a/pkg/gui/controllers/submodules_controller.go b/pkg/gui/controllers/submodules_controller.go index c0f52bed1..c425453aa 100644 --- a/pkg/gui/controllers/submodules_controller.go +++ b/pkg/gui/controllers/submodules_controller.go @@ -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) } diff --git a/pkg/gui/controllers/switch_to_diff_files_controller.go b/pkg/gui/controllers/switch_to_diff_files_controller.go index cdf943cfe..94c3c5712 100644 --- a/pkg/gui/controllers/switch_to_diff_files_controller.go +++ b/pkg/gui/controllers/switch_to_diff_files_controller.go @@ -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() diff --git a/pkg/gui/controllers/switch_to_sub_commits_controller.go b/pkg/gui/controllers/switch_to_sub_commits_controller.go index 9257e33d3..8bee8d7e6 100644 --- a/pkg/gui/controllers/switch_to_sub_commits_controller.go +++ b/pkg/gui/controllers/switch_to_sub_commits_controller.go @@ -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 } diff --git a/pkg/gui/controllers/worktrees_controller.go b/pkg/gui/controllers/worktrees_controller.go index b861d6b04..0e34ec59f 100644 --- a/pkg/gui/controllers/worktrees_controller.go +++ b/pkg/gui/controllers/worktrees_controller.go @@ -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) } diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 790f4c84b..da27dac1a 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -91,10 +91,10 @@ 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) @@ -246,7 +246,7 @@ type ( type HasKeybindings interface { GetKeybindings(opts KeybindingsOpts) []*Binding GetMouseKeybindings(opts KeybindingsOpts) []*gocui.ViewMouseBinding - GetOnClick() func() error + GetOnDoubleClick() func() error GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error } From fe5df2334b8bcc085b4503ba09369f6365de5c76 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 25 Mar 2026 16:35:16 +0100 Subject: [PATCH 2/5] Document some of the methods of HasKeybindings We have some documentation for the corresponding setters in IBaseContext, but that's part of the controller infrastructure and not client facing. For somebody implementing a new view, this is where they will probably look for what methods they can override. --- pkg/gui/types/context.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index da27dac1a..42c7ff293 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -246,7 +246,13 @@ type ( type HasKeybindings interface { GetKeybindings(opts KeybindingsOpts) []*Binding GetMouseKeybindings(opts KeybindingsOpts) []*gocui.ViewMouseBinding + + // 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 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 } From 61b72cef38c8763727afe0bac144c5e5fb95a00c Mon Sep 17 00:00:00 2001 From: blakemckeany Date: Wed, 25 Mar 2026 14:46:33 +0100 Subject: [PATCH 3/5] Add GetVisualDepth method to FileTree/CommitFileTree --- pkg/gui/filetree/commit_file_tree.go | 4 + pkg/gui/filetree/file_tree.go | 5 + pkg/gui/filetree/node.go | 26 ++++-- pkg/gui/filetree/node_test.go | 134 +++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 pkg/gui/filetree/node_test.go diff --git a/pkg/gui/filetree/commit_file_tree.go b/pkg/gui/filetree/commit_file_tree.go index bf6d1251c..38232ea3c 100644 --- a/pkg/gui/filetree/commit_file_tree.go +++ b/pkg/gui/filetree/commit_file_tree.go @@ -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 } diff --git a/pkg/gui/filetree/file_tree.go b/pkg/gui/filetree/file_tree.go index 9840fd8dd..a70876221 100644 --- a/pkg/gui/filetree/file_tree.go +++ b/pkg/gui/filetree/file_tree.go @@ -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 } diff --git a/pkg/gui/filetree/node.go b/pkg/gui/filetree/node.go index 97d5232b5..5d9cefcd4 100644 --- a/pkg/gui/filetree/node.go +++ b/pkg/gui/filetree/node.go @@ -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) { diff --git a/pkg/gui/filetree/node_test.go b/pkg/gui/filetree/node_test.go new file mode 100644 index 000000000..d44441f39 --- /dev/null +++ b/pkg/gui/filetree/node_test.go @@ -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") + }) + } +} From 45678401987fb401f4fb306c75d745ae0f37b27e Mon Sep 17 00:00:00 2001 From: blakemckeany Date: Wed, 25 Mar 2026 16:45:47 +0100 Subject: [PATCH 4/5] Add GetOnClick to HasKeybindings Can be used for doing additional click handling in list views. Like the GetOnDoubleClick hook we should try to find a better design for this than putting it in HasKeybindings and BaseContext, since it is only used by list contexts. Co-authored-by: Stefan Haller --- pkg/gui/context/base_context.go | 15 +++++++++++++++ pkg/gui/controllers/attach.go | 1 + pkg/gui/controllers/base_controller.go | 4 ++++ pkg/gui/controllers/list_controller.go | 7 +++++++ pkg/gui/types/context.go | 9 +++++++++ 5 files changed, 36 insertions(+) diff --git a/pkg/gui/context/base_context.go b/pkg/gui/context/base_context.go index a21e95e3f..e4993828c 100644 --- a/pkg/gui/context/base_context.go +++ b/pkg/gui/context/base_context.go @@ -16,6 +16,7 @@ type BaseContext struct { keybindingsFns []types.KeybindingsFn mouseKeybindingsFns []types.MouseKeybindingsFn onDoubleClickFn func() error + onClickFn func(opts gocui.ViewMouseBindingOpts) error onClickFocusedMainViewFn onClickFocusedMainViewFn onRenderToMainFn func() onFocusFns []onFocusFn @@ -141,6 +142,7 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() { self.onFocusFns = nil self.onFocusLostFns = nil self.onDoubleClickFn = nil + self.onClickFn = nil self.onClickFocusedMainViewFn = nil self.onRenderToMainFn = nil } @@ -154,6 +156,15 @@ func (self *BaseContext) AddOnDoubleClickFn(fn func() error) { } } +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") + } + self.onClickFn = fn + } +} + func (self *BaseContext) AddOnClickFocusedMainViewFn(fn onClickFocusedMainViewFn) { if fn != nil { if self.onClickFocusedMainViewFn != nil { @@ -167,6 +178,10 @@ func (self *BaseContext) GetOnDoubleClick() func() error { return self.onDoubleClickFn } +func (self *BaseContext) GetOnClick() func(opts gocui.ViewMouseBindingOpts) error { + return self.onClickFn +} + func (self *BaseContext) GetOnClickFocusedMainView() onClickFocusedMainViewFn { return self.onClickFocusedMainViewFn } diff --git a/pkg/gui/controllers/attach.go b/pkg/gui/controllers/attach.go index b703c28c4..c67c415a3 100644 --- a/pkg/gui/controllers/attach.go +++ b/pkg/gui/controllers/attach.go @@ -7,6 +7,7 @@ func AttachControllers(context types.Context, controllers ...types.IController) context.AddKeybindingsFn(controller.GetKeybindings) context.AddMouseKeybindingsFn(controller.GetMouseKeybindings) context.AddOnDoubleClickFn(controller.GetOnDoubleClick()) + context.AddOnClickFn(controller.GetOnClick()) context.AddOnClickFocusedMainViewFn(controller.GetOnClickFocusedMainView()) context.AddOnRenderToMainFn(controller.GetOnRenderToMain()) context.AddOnFocusFn(controller.GetOnFocus()) diff --git a/pkg/gui/controllers/base_controller.go b/pkg/gui/controllers/base_controller.go index d85f7f052..afd6cf210 100644 --- a/pkg/gui/controllers/base_controller.go +++ b/pkg/gui/controllers/base_controller.go @@ -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 } diff --git a/pkg/gui/controllers/list_controller.go b/pkg/gui/controllers/list_controller.go index f07c5994d..a56860bab 100644 --- a/pkg/gui/controllers/list_controller.go +++ b/pkg/gui/controllers/list_controller.go @@ -246,7 +246,14 @@ func (self *ListController) HandleClick(opts gocui.ViewMouseBindingOpts) error { 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 } diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 42c7ff293..afc8e11eb 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -98,6 +98,9 @@ type IBaseContext interface { // 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)) @@ -251,6 +254,12 @@ type HasKeybindings interface { // 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 From ee3bb06b2a69c5483794ae6d7c9f0c3d14f2f71d Mon Sep 17 00:00:00 2001 From: blakemckeany Date: Fri, 13 Mar 2026 11:11:32 +1300 Subject: [PATCH 5/5] Add support for clicking on arrows in the file list to expand/collapse directories Co-authored-by: Stefan Haller --- .../controllers/commits_files_controller.go | 24 ++++++ pkg/gui/controllers/files_controller.go | 24 ++++++ .../tests/file/click_arrow_to_collapse.go | 86 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 4 files changed, 135 insertions(+) create mode 100644 pkg/integration/tests/file/click_arrow_to_collapse.go diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 7d677b756..e4a7cba68 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -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() diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 557b40e6a..acc615cce 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -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() { diff --git a/pkg/integration/tests/file/click_arrow_to_collapse.go b/pkg/integration/tests/file/click_arrow_to_collapse.go new file mode 100644 index 000000000..26d8b8d6a --- /dev/null +++ b/pkg/integration/tests/file/click_arrow_to_collapse.go @@ -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(), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index a10bc210e..85183406b 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -210,6 +210,7 @@ var tests = []*components.IntegrationTest{ diff.DiffNonStickyRange, diff.IgnoreWhitespace, diff.RenameSimilarityThresholdChange, + file.ClickArrowToCollapse, file.CollapseExpand, file.CopyMenu, file.DirWithUntrackedFile,