From 19c702f9abd6e0600ce28353d71ef2aa35bb50bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=C6=B0=C6=A1ng=20Duy=20Kh=C3=A1nh?= Date: Tue, 28 Jul 2026 09:21:48 +0700 Subject: [PATCH] Add a keybinding to collapse the parent directory of the selected file (#1) When a file is deeply nested (e.g. dir/subdir/file), there was no quick way to collapse an ancestor directory without walking back up the tree one row at a time. This adds in the Files and Commit Files panels to jump the selection to the parent directory and collapse it, skipping over directories that got merged away by path compression. Pressing it again from the root collapses the root itself. Relates to upstream repo's #4984 and #4095. --- pkg/config/user_config.go | 2 + .../controllers/commits_files_controller.go | 28 +++++++++ pkg/gui/controllers/files_controller.go | 54 ++++++++++++++++++ pkg/i18n/english.go | 4 ++ .../tests/commit/collapse_parent_files.go | 54 ++++++++++++++++++ pkg/integration/tests/file/collapse_parent.go | 57 +++++++++++++++++++ pkg/integration/tests/test_list.go | 2 + 7 files changed, 201 insertions(+) create mode 100644 pkg/integration/tests/commit/collapse_parent_files.go create mode 100644 pkg/integration/tests/file/collapse_parent.go diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 30ce0377d..3b68c0e2a 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -583,6 +583,7 @@ type KeybindingFilesConfig struct { CopyFileInfoToClipboard Keybinding `yaml:"copyFileInfoToClipboard"` CollapseAll Keybinding `yaml:"collapseAll"` ExpandAll Keybinding `yaml:"expandAll"` + CollapseParent Keybinding `yaml:"collapseParent"` } type KeybindingBranchesConfig struct { @@ -1099,6 +1100,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { CopyFileInfoToClipboard: Keybinding{"y"}, CollapseAll: Keybinding{"-"}, ExpandAll: Keybinding{"="}, + CollapseParent: Keybinding{""}, }, Branches: KeybindingBranchesConfig{ CopyPullRequestURL: Keybinding{""}, diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index d129b3f90..3a80786fe 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -132,6 +132,13 @@ func (self *CommitFilesController) GetKeybindings(opts types.KeybindingsOpts) [] Tooltip: self.c.Tr.ExpandAllTooltip, GetDisabledReason: self.require(self.isInTreeMode), }, + { + Keys: opts.GetKeys(opts.Config.Files.CollapseParent), + Handler: self.collapseParent, + Description: self.c.Tr.CollapseParent, + Tooltip: self.c.Tr.CollapseParentTooltip, + GetDisabledReason: self.require(self.isInTreeMode), + }, } return bindings @@ -603,6 +610,27 @@ func (self *CommitFilesController) expandAll() error { return nil } +func (self *CommitFilesController) collapseParent() error { + node := self.getSelectedItem() + if node == nil { + return nil + } + + viewModel := self.context().CommitFileTreeViewModel + + parentPath, idx, found := collapseParentPath(node.GetInternalPath(), viewModel.GetIndexForPath) + if !found { + return nil + } + + viewModel.CollapsedPaths().Collapse(parentPath) + viewModel.SetSelectedLineIdx(idx) + + self.c.PostRefreshUpdate(self.context()) + + return nil +} + func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error { return func(mainViewName string, clickedLineIdx int) error { node := self.getSelectedItem() diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 656da1bf5..b268d0adf 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -206,6 +206,13 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types Tooltip: self.c.Tr.ExpandAllTooltip, GetDisabledReason: self.require(self.isInTreeMode), }, + { + Keys: opts.GetKeys(opts.Config.Files.CollapseParent), + Handler: self.collapseParent, + Description: self.c.Tr.CollapseParent, + Tooltip: self.c.Tr.CollapseParentTooltip, + GetDisabledReason: self.require(self.isInTreeMode), + }, } } @@ -720,6 +727,53 @@ func (self *FilesController) expandAll() error { return nil } +func (self *FilesController) collapseParent() error { + node := self.context().GetSelected() + if node == nil { + return nil + } + + viewModel := self.context().FileTreeViewModel + + parentPath, idx, found := collapseParentPath(node.GetInternalPath(), viewModel.GetIndexForPath) + if !found { + return nil + } + + viewModel.CollapsedPaths().Collapse(parentPath) + viewModel.SetSelectedLineIdx(idx) + + self.c.PostRefreshUpdate(self.context()) + + return nil +} + +// collapseParentPath walks up the internal-path segments of the tree from the +// given node until it finds an existing ancestor node, since a compressed +// directory (e.g. "dir/subdir" merged into one visual node) means the +// immediate one-segment-up path isn't always addressable. The root node's +// parent is itself, so backspacing from the top collapses the root. +func collapseParentPath(internalPath string, getIndexForPath func(string) (int, bool)) (string, int, bool) { + segments := strings.Split(internalPath, "/") + + if len(segments) == 1 { + if idx, ok := getIndexForPath(internalPath); ok { + return internalPath, idx, true + } + return "", 0, false + } + + for len(segments) > 1 { + segments = segments[:len(segments)-1] + candidate := strings.Join(segments, "/") + if idx, ok := getIndexForPath(candidate); ok { + return candidate, idx, true + } + } + + return "", 0, false +} + func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { node := self.context().GetSelected() if node == nil { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 2e83fed9b..ecd3389cc 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -294,6 +294,8 @@ type TranslationSet struct { CollapseAllTooltip string ExpandAll string ExpandAllTooltip string + CollapseParent string + CollapseParentTooltip string DisabledInFlatView string FileEnter string FileEnterTooltip string @@ -1438,6 +1440,8 @@ func EnglishTranslationSet() *TranslationSet { CollapseAllTooltip: "Collapse all directories in the files tree", ExpandAll: "Expand all files", ExpandAllTooltip: "Expand all directories in the file tree", + CollapseParent: "Collapse parent directory", + CollapseParentTooltip: "Move selection up to the parent directory and collapse it", DisabledInFlatView: "Not available in flat view", FileEnter: `Stage lines / Collapse directory`, FileEnterTooltip: "If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it.", diff --git a/pkg/integration/tests/commit/collapse_parent_files.go b/pkg/integration/tests/commit/collapse_parent_files.go new file mode 100644 index 000000000..4419e7b0d --- /dev/null +++ b/pkg/integration/tests/commit/collapse_parent_files.go @@ -0,0 +1,54 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CollapseParentFiles = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Pressing backspace in the commit files panel jumps to the parent directory and collapses it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("dir1/subd1/subfile0", "file0\n") + shell.CreateFileAndAdd("dir2/d2_file1", "d2f1 content\n") + shell.Commit("add files in two dirs") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("add files in two dirs").IsSelected(), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Equals("▼ /").IsSelected(), + Equals(" ▼ dir1/subd1"), + Equals(" A subfile0"), + Equals(" ▼ dir2"), + Equals(" A d2_file1"), + ). + NavigateToLine(Contains("subfile0")) + + // backspace jumps to and collapses the immediate (compressed) parent "dir1/subd1" + t.Views().CommitFiles(). + Press(keys.Files.CollapseParent). + Lines( + Equals("▼ /"), + Equals(" ▶ dir1/subd1").IsSelected(), + Equals(" ▼ dir2"), + Equals(" A d2_file1"), + ) + + // backspace again jumps up to and collapses the root + t.Views().CommitFiles(). + Press(keys.Files.CollapseParent). + Lines( + Equals("▶ /").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/file/collapse_parent.go b/pkg/integration/tests/file/collapse_parent.go new file mode 100644 index 000000000..185ed5052 --- /dev/null +++ b/pkg/integration/tests/file/collapse_parent.go @@ -0,0 +1,57 @@ +package file + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CollapseParent = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Pressing backspace on a selected file jumps to its parent directory and collapses it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateDir("dir") + shell.CreateDir("dir/subdir") + shell.CreateFile("dir/subdir/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/subdir"), + Equals(" ?? file-one"), + Equals(" ▼ dir2"), + Equals(" ?? file-two"), + ). + // select "file-one" nested two levels deep + NavigateToLine(Contains("file-one")) + + // backspace jumps to and collapses the immediate (compressed) parent "dir/subdir" + t.Views().Files(). + Press(keys.Files.CollapseParent). + Lines( + Equals("▼ /"), + Equals(" ▶ dir/subdir").IsSelected(), + Equals(" ▼ dir2"), + Equals(" ?? file-two"), + ) + + // backspace again jumps up to and collapses the root + t.Views().Files(). + Press(keys.Files.CollapseParent). + Lines( + Equals("▶ /").IsSelected(), + ) + + // backspace on an already-collapsed root is a no-op + t.Views().Files(). + Press(keys.Files.CollapseParent). + Lines( + Equals("▶ /").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 07a12e2be..8291a6a10 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -110,6 +110,7 @@ var tests = []*components.IntegrationTest{ commit.CheckoutFileFromCommit, commit.CheckoutFileFromRangeSelectionOfCommits, commit.CheckoutFileWithLocalModifications, + commit.CollapseParentFiles, commit.Commit, commit.CommitMultiline, commit.CommitSkipHooks, @@ -227,6 +228,7 @@ var tests = []*components.IntegrationTest{ diff.RenameSimilarityThresholdChange, file.ClickArrowToCollapse, file.CollapseExpand, + file.CollapseParent, file.CopyMenu, file.DirWithUntrackedFile, file.DiscardAllDirChanges,