diff --git a/pkg/gui/context/base_context.go b/pkg/gui/context/base_context.go index 7c6e9b617..e4993828c 100644 --- a/pkg/gui/context/base_context.go +++ b/pkg/gui/context/base_context.go @@ -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 } 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..c67c415a3 100644 --- a/pkg/gui/controllers/attach.go +++ b/pkg/gui/controllers/attach.go @@ -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()) diff --git a/pkg/gui/controllers/base_controller.go b/pkg/gui/controllers/base_controller.go index 0e1229222..afd6cf210 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 } @@ -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/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 8cc2ca5e2..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() { @@ -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}) }) diff --git a/pkg/gui/controllers/list_controller.go b/pkg/gui/controllers/list_controller.go index 6da196a60..a56860bab 100644 --- a/pkg/gui/controllers/list_controller.go +++ b/pkg/gui/controllers/list_controller.go @@ -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 } 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/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") + }) + } +} diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 790f4c84b..afc8e11eb 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -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 } 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,