From 60316388c0f31bffaf3938023b0d48b5a0966540 Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Sun, 8 Feb 2026 07:22:21 +1100 Subject: [PATCH 1/4] Rename GetFilter to GetStatusFilter on IFileTree This avoids a naming collision with GetFilter from the IFilterableContext interface, which will be implemented by FileTreeViewModel in the next commit. Co-Authored-By: Claude Opus 4.6 --- pkg/gui/controllers/files_controller.go | 8 ++++---- pkg/gui/controllers/helpers/refresh_helper.go | 4 ++-- pkg/gui/filetree/file_tree.go | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 9efe2f8f6..5b6cbcef2 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -449,7 +449,7 @@ func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) e if len(unstagedSelectedNodes) > 0 { var extraArgs []string - if self.context().GetFilter() == filetree.DisplayTracked { + if self.context().GetStatusFilter() == filetree.DisplayTracked { extraArgs = []string{"-u"} } @@ -648,7 +648,7 @@ func (self *FilesController) toggleStagedAllWithLock() error { return err } - onlyTrackedFiles := self.context().GetFilter() == filetree.DisplayTracked + onlyTrackedFiles := self.context().GetStatusFilter() == filetree.DisplayTracked if err := self.c.Git().WorkingTree.StageAll(onlyTrackedFiles); err != nil { return err } @@ -836,7 +836,7 @@ func (self *FilesController) isResolvingConflicts() bool { } func (self *FilesController) handleStatusFilterPressed() error { - currentFilter := self.context().GetFilter() + currentFilter := self.context().GetStatusFilter() return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.FilteringMenuTitle, Items: []*types.MenuItem{ @@ -904,7 +904,7 @@ func (self *FilesController) filteringLabel(filter filetree.FileTreeDisplayFilte } func (self *FilesController) setStatusFiltering(filter filetree.FileTreeDisplayFilter) error { - previousFilter := self.context().GetFilter() + previousFilter := self.context().GetStatusFilter() self.context().FileTreeViewModel.SetStatusFilter(filter) self.c.Contexts().Files.GetView().Subtitle = self.filteringLabel(filter) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 8ebc76d16..332b38091 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -584,11 +584,11 @@ func (self *RefreshHelper) refreshStateFiles() error { // only taking over the filter if it hasn't already been set by the user. if conflictFileCount > 0 && prevConflictFileCount == 0 { - if fileTreeViewModel.GetFilter() == filetree.DisplayAll { + if fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll { fileTreeViewModel.SetStatusFilter(filetree.DisplayConflicted) self.c.Contexts().Files.GetView().Subtitle = self.c.Tr.FilterLabelConflictingFiles } - } else if conflictFileCount == 0 && fileTreeViewModel.GetFilter() == filetree.DisplayConflicted { + } else if conflictFileCount == 0 && fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted { fileTreeViewModel.SetStatusFilter(filetree.DisplayAll) self.c.Contexts().Files.GetView().Subtitle = "" } diff --git a/pkg/gui/filetree/file_tree.go b/pkg/gui/filetree/file_tree.go index d0056a0a8..8190c379a 100644 --- a/pkg/gui/filetree/file_tree.go +++ b/pkg/gui/filetree/file_tree.go @@ -46,7 +46,7 @@ type IFileTree interface { GetFile(path string) *models.File GetAllItems() []*FileNode GetAllFiles() []*models.File - GetFilter() FileTreeDisplayFilter + GetStatusFilter() FileTreeDisplayFilter GetRoot() *FileNode } @@ -210,6 +210,6 @@ func (self *FileTree) CollapsedPaths() *CollapsedPaths { return self.collapsedPaths } -func (self *FileTree) GetFilter() FileTreeDisplayFilter { +func (self *FileTree) GetStatusFilter() FileTreeDisplayFilter { return self.filter } From ed9693ebcc345d2ccb4de0d831dbc40639d91450 Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Sat, 7 Mar 2026 20:26:21 +1100 Subject: [PATCH 2/4] Reset PrevSearchIndex when opening filter prompt When opening a filter prompt, reset PrevSearchIndex to -1 to avoid stale search state from a previous search/filter session. Co-Authored-By: Claude Opus 4.6 --- pkg/gui/controllers/helpers/search_helper.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/gui/controllers/helpers/search_helper.go b/pkg/gui/controllers/helpers/search_helper.go index f15c6dda7..506e8a736 100644 --- a/pkg/gui/controllers/helpers/search_helper.go +++ b/pkg/gui/controllers/helpers/search_helper.go @@ -33,6 +33,8 @@ func NewSearchHelper( func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) error { state := self.searchState() + state.PrevSearchIndex = -1 + state.Context = context self.searchPrefixView().SetContent(context.FilterPrefix(self.c.Tr)) From 8728da7985b08744af14312921d02f00f02fd074 Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Sat, 7 Mar 2026 20:26:35 +1100 Subject: [PATCH 3/4] Only reset selection in ReApplyFilter when search prompt is active Without this check, the selection was being reset to 0 whenever ReApplyFilter was called for the current filter context, even when the user wasn't actively typing in the search prompt (e.g. when the model updates in the background). This was causing unexpected cursor jumps. Co-Authored-By: Claude Opus 4.6 --- pkg/gui/controllers/helpers/search_helper.go | 2 +- ...ter_preserves_selection_on_model_change.go | 63 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 pkg/integration/tests/filter_and_search/filter_preserves_selection_on_model_change.go diff --git a/pkg/gui/controllers/helpers/search_helper.go b/pkg/gui/controllers/helpers/search_helper.go index 506e8a736..9b3dcec64 100644 --- a/pkg/gui/controllers/helpers/search_helper.go +++ b/pkg/gui/controllers/helpers/search_helper.go @@ -240,7 +240,7 @@ func (self *SearchHelper) ReApplyFilter(context types.Context) { filterableContext, ok := context.(types.IFilterableContext) if ok { state := self.searchState() - if context == state.Context { + if context == state.Context && self.c.Context().Current().GetKey() == self.c.Contexts().Search.GetKey() { filterableContext.SetSelection(0) filterableContext.GetView().SetOriginY(0) } diff --git a/pkg/integration/tests/filter_and_search/filter_preserves_selection_on_model_change.go b/pkg/integration/tests/filter_and_search/filter_preserves_selection_on_model_change.go new file mode 100644 index 000000000..eaafbaa66 --- /dev/null +++ b/pkg/integration/tests/filter_and_search/filter_preserves_selection_on_model_change.go @@ -0,0 +1,63 @@ +package filter_and_search + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FilterPreservesSelectionOnModelChange = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Verify that when a filter is active and the model changes, the selection is preserved", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.NewBranch("branch-alpha") + shell.NewBranch("branch-beta") + shell.NewBranch("branch-gamma") + shell.NewBranch("checked-out-branch") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("checked-out-branch").IsSelected(), + Contains("branch-alpha"), + Contains("branch-beta"), + Contains("branch-gamma"), + Contains("master"), + ). + FilterOrSearch("branch-"). + Lines( + Contains("branch-alpha").IsSelected(), + Contains("branch-beta"), + Contains("branch-gamma"), + ). + // Move cursor to a non-zero position + SelectNextItem(). + SelectNextItem(). + Lines( + Contains("branch-alpha"), + Contains("branch-beta"), + Contains("branch-gamma").IsSelected(), + ) + + // Trigger a model update while staying on the Branches view. + // Using a shell command that creates a new branch sorting after + // branch-gamma, so the selection index still points to the same item. + t.GlobalPress(keys.Universal.ExecuteShellCommand) + t.ExpectPopup().Prompt(). + Title(Equals("Shell command:")). + Type("git branch branch-zeta"). + Confirm() + + // Verify that the selection is still on branch-gamma (not reset to 0) + t.Views().Branches(). + Lines( + Contains("branch-alpha"), + Contains("branch-beta"), + Contains("branch-gamma").IsSelected(), + Contains("branch-zeta"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 804a8764f..b98de18d8 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -238,6 +238,7 @@ var tests = []*components.IntegrationTest{ filter_and_search.FilterMenuByKeybinding, filter_and_search.FilterMenuCancelFilterWithEscape, filter_and_search.FilterMenuWithNoKeybindings, + filter_and_search.FilterPreservesSelectionOnModelChange, filter_and_search.FilterRemoteBranches, filter_and_search.FilterRemotes, filter_and_search.FilterSearchHistory, From 615c566ac409dc9e996b8a8e4ba44435f36bf895 Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Sun, 8 Feb 2026 07:23:05 +1100 Subject: [PATCH 4/4] Filter file views rather than search Change working tree files and commit files panels to use filtering (reducing the list) instead of search (highlighting matches). This matches the behavior of other filterable views. The text filter matches against the full file path, not just the filename, which is more useful for navigating large directory trees. When toggling a directory for a custom patch while a text filter is active, only the visible filtered files in the directory are affected, consistent with how staging a directory in the files panel works. Co-Authored-By: Claude Opus 4.6 --- docs-master/keybindings/Keybindings_en.md | 4 +- docs-master/keybindings/Keybindings_ja.md | 4 +- docs-master/keybindings/Keybindings_ko.md | 4 +- docs-master/keybindings/Keybindings_nl.md | 4 +- docs-master/keybindings/Keybindings_pl.md | 4 +- docs-master/keybindings/Keybindings_pt.md | 4 +- docs-master/keybindings/Keybindings_ru.md | 4 +- docs-master/keybindings/Keybindings_zh-CN.md | 4 +- pkg/commands/git_commands/working_tree.go | 20 ++- pkg/gui/context/commit_files_context.go | 12 +- pkg/gui/context/working_tree_context.go | 12 +- .../controllers/commits_files_controller.go | 20 ++- pkg/gui/controllers/files_controller.go | 121 +++++++++++++++--- pkg/gui/controllers/submodules_controller.go | 2 +- .../switch_to_diff_files_controller.go | 2 +- pkg/gui/filetree/commit_file_tree.go | 27 +++- .../filetree/commit_file_tree_view_model.go | 53 ++++++++ pkg/gui/filetree/file_filter.go | 48 +++++++ pkg/gui/filetree/file_tree.go | 33 ++++- pkg/gui/filetree/file_tree_view_model.go | 55 +++++++- .../filter_and_search/filter_commit_files.go | 2 +- .../filter_commit_files_toggle_directory.go | 61 +++++++++ .../tests/filter_and_search/filter_files.go | 2 +- .../filter_files_stage_all.go | 50 ++++++++ .../filter_files_stage_directory.go | 50 ++++++++ .../tests/filter_and_search/nested_filter.go | 12 +- .../nested_filter_transient.go | 6 +- .../tests/patch_building/toggle_directory.go | 64 +++++++++ pkg/integration/tests/test_list.go | 4 + 29 files changed, 595 insertions(+), 93 deletions(-) create mode 100644 pkg/gui/filetree/file_filter.go create mode 100644 pkg/integration/tests/filter_and_search/filter_commit_files_toggle_directory.go create mode 100644 pkg/integration/tests/filter_and_search/filter_files_stage_all.go create mode 100644 pkg/integration/tests/filter_and_search/filter_files_stage_directory.go create mode 100644 pkg/integration/tests/patch_building/toggle_directory.go diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 9e2bce45b..2a25d1f62 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -71,7 +71,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | -| `` / `` | Search the current view by text | | +| `` / `` | Filter the current view by text | | ## Commit summary @@ -160,7 +160,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | -| `` / `` | Search the current view by text | | +| `` / `` | Filter the current view by text | | ## Input prompt diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index ce9657e4e..899493323 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -119,7 +119,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | `` - `` | すべてのファイルを折りたたむ | ファイルツリー内のすべてのディレクトリを折りたたみます | | `` = `` | すべてのファイルを展開 | ファイルツリー内のすべてのディレクトリを展開します | | `` 0 `` | メインビューにフォーカス | | -| `` / `` | 現在のビューをテキストで検索 | | +| `` / `` | 現在のビューをテキストでフィルタリング | | ## コミット概要 @@ -242,7 +242,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | `` - `` | すべてのファイルを折りたたむ | ファイルツリー内のすべてのディレクトリを折りたたみます | | `` = `` | すべてのファイルを展開 | ファイルツリー内のすべてのディレクトリを展開します | | `` 0 `` | メインビューにフォーカス | | -| `` / `` | 現在のビューをテキストで検索 | | +| `` / `` | 現在のビューをテキストでフィルタリング | | ## メインパネル(ステージング) diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index 134c0ae04..e4e05b884 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -349,7 +349,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | -| `` / `` | 검색 시작 | | +| `` / `` | Filter the current view by text | | ## 커밋메시지 @@ -405,7 +405,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | -| `` / `` | 검색 시작 | | +| `` / `` | Filter the current view by text | | ## 확인 패널 diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index ec88b025d..e1d99162c 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -84,7 +84,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | -| `` / `` | Start met zoeken | | +| `` / `` | Filter the current view by text | | ## Bevestigingspaneel @@ -149,7 +149,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | -| `` / `` | Start met zoeken | | +| `` / `` | Filter the current view by text | | ## Commits diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index bb0907326..28c9f73a8 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -260,7 +260,7 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | -| `` / `` | Szukaj w bieżącym widoku po tekście | | +| `` / `` | Filtruj bieżący widok po tekście | | ## Pliki commita @@ -280,7 +280,7 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | -| `` / `` | Szukaj w bieżącym widoku po tekście | | +| `` / `` | Filtruj bieżący widok po tekście | | ## Podsumowanie commita diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index d55e4ae1a..217d477d4 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -84,7 +84,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` - `` | Recolher todos os arquivos | Recolher todos os diretórios na árvore de arquivos | | `` = `` | Expandir todos os arquivos | Expandir todos os diretórios na árvore do arquivo | | `` 0 `` | Focus main view | | -| `` / `` | Search the current view by text | | +| `` / `` | Filter the current view by text | | ## Branches locais @@ -153,7 +153,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` - `` | Recolher todos os arquivos | Recolher todos os diretórios na árvore de arquivos | | `` = `` | Expandir todos os arquivos | Expandir todos os diretórios na árvore do arquivo | | `` 0 `` | Focus main view | | -| `` / `` | Search the current view by text | | +| `` / `` | Filter the current view by text | | ## Commits diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 085b4e00b..20c0056b6 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -308,7 +308,7 @@ _Связки клавиш_ | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | -| `` / `` | Найти | | +| `` / `` | Filter the current view by text | | ## Статус @@ -399,7 +399,7 @@ _Связки клавиш_ | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | -| `` / `` | Найти | | +| `` / `` | Filter the current view by text | | ## Хранилище diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index c29102c3e..f1b32386a 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -183,7 +183,7 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | `` - `` | 折叠全部文件 | 折叠文件树中的全部目录 | | `` = `` | 展开全部文件 | 展开文件树中的全部目录 | | `` 0 `` | 聚焦主视图 | | -| `` / `` | 开始搜索 | | +| `` / `` | 通过文本过滤当前视图 | | ## 文件 @@ -216,7 +216,7 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | `` - `` | 折叠全部文件 | 折叠文件树中的全部目录 | | `` = `` | 展开全部文件 | 展开文件树中的全部目录 | | `` 0 `` | 聚焦主视图 | | -| `` / `` | 开始搜索 | | +| `` / `` | 通过文本过滤当前视图 | | ## 本地分支 diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index 55159a7f9..664436066 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -253,11 +253,14 @@ func (self *WorkingTreeCommands) Exclude(filename string) error { // WorktreeFileDiff returns the diff of a file func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, cached bool) string { // for now we assume an error means the file was deleted - s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached).RunWithOutput() + s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, nil).RunWithOutput() return s } -func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool) *oscommands.CmdObj { +// WorktreeFileDiffCmdObj returns a command object for diffing a file or directory +// in the working tree. When pathOverrides is non-empty, those paths are used instead of +// the node's path (used to diff only filtered/visible files within a directory). +func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, pathOverrides []string) *oscommands.CmdObj { colorArg := self.pagerConfig.GetColorArg() if plain { colorArg = "never" @@ -270,6 +273,11 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain useExtDiff := extDiffCmd != "" && !plain useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() && !plain + paths := pathOverrides + if len(paths) == 0 { + paths = []string{node.GetPath()} + } + cmdArgs := NewGitCmd("diff"). ConfigIf(useExtDiff, "diff.external="+extDiffCmd). ArgIfElse(useExtDiff || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff"). @@ -282,7 +290,7 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain ArgIf(noIndex, "--no-index"). Arg("--"). ArgIf(noIndex, "/dev/null"). - Arg(node.GetPath()). + Arg(paths...). ArgIf(prevPath != "", prevPath). Dir(self.repoPaths.worktreePath). ToArgv() @@ -293,10 +301,10 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain // ShowFileDiff get the diff of specified from and to. Typically this will be used for a single commit so it'll be 123abc^..123abc // but when we're in diff mode it could be any 'from' to any 'to'. The reverse flag is also here thanks to diff mode. func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bool, fileName string, plain bool) (string, error) { - return self.ShowFileDiffCmdObj(from, to, reverse, fileName, plain).RunWithOutput() + return self.ShowFileDiffCmdObj(from, to, reverse, []string{fileName}, plain).RunWithOutput() } -func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileName string, plain bool) *oscommands.CmdObj { +func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, plain bool) *oscommands.CmdObj { contextSize := self.UserConfig().Git.DiffContextSize colorArg := self.pagerConfig.GetColorArg() @@ -321,7 +329,7 @@ func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reve ArgIf(reverse, "-R"). ArgIf(!plain && self.UserConfig().Git.IgnoreWhitespaceInDiffView, "--ignore-all-space"). Arg("--"). - Arg(fileName). + Arg(fileNames...). Dir(self.repoPaths.worktreePath). ToArgv() diff --git a/pkg/gui/context/commit_files_context.go b/pkg/gui/context/commit_files_context.go index b4a14045c..f819a2eb4 100644 --- a/pkg/gui/context/commit_files_context.go +++ b/pkg/gui/context/commit_files_context.go @@ -3,7 +3,6 @@ package context import ( "fmt" - "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/filetree" "github.com/jesseduffield/lazygit/pkg/gui/presentation" @@ -17,13 +16,12 @@ type CommitFilesContext struct { *filetree.CommitFileTreeViewModel *ListContextTrait *DynamicTitleBuilder - *SearchTrait } var ( _ types.IListContext = (*CommitFilesContext)(nil) _ types.DiffableContext = (*CommitFilesContext)(nil) - _ types.ISearchableContext = (*CommitFilesContext)(nil) + _ types.IFilterableContext = (*CommitFilesContext)(nil) ) func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext { @@ -48,7 +46,6 @@ func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext { ctx := &CommitFilesContext{ CommitFileTreeViewModel: viewModel, DynamicTitleBuilder: NewDynamicTitleBuilder(c.Tr.CommitFilesDynamicTitle), - SearchTrait: NewSearchTrait(c), ListContextTrait: &ListContextTrait{ Context: NewSimpleContext( NewBaseContext(NewBaseContextOpts{ @@ -68,9 +65,6 @@ func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext { }, } - ctx.GetView().SetRenderSearchStatus(ctx.SearchTrait.RenderSearchStatus) - ctx.GetView().SetOnSelectItem(ctx.OnSearchSelect) - return ctx } @@ -93,10 +87,6 @@ func (self *CommitFilesContext) GetFromAndToForDiff() (string, string) { return ref.ParentRefName(), ref.RefName() } -func (self *CommitFilesContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition { - return nil -} - func (self *CommitFilesContext) ReInit(ref models.Ref, refRange *types.RefRange) { self.SetRef(ref) self.SetRefRange(refRange) diff --git a/pkg/gui/context/working_tree_context.go b/pkg/gui/context/working_tree_context.go index d37306dc8..d82037e44 100644 --- a/pkg/gui/context/working_tree_context.go +++ b/pkg/gui/context/working_tree_context.go @@ -1,7 +1,6 @@ package context import ( - "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/filetree" "github.com/jesseduffield/lazygit/pkg/gui/presentation" @@ -13,12 +12,11 @@ import ( type WorkingTreeContext struct { *filetree.FileTreeViewModel *ListContextTrait - *SearchTrait } var ( _ types.IListContext = (*WorkingTreeContext)(nil) - _ types.ISearchableContext = (*WorkingTreeContext)(nil) + _ types.IFilterableContext = (*WorkingTreeContext)(nil) ) func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext { @@ -38,7 +36,6 @@ func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext { } ctx := &WorkingTreeContext{ - SearchTrait: NewSearchTrait(c), FileTreeViewModel: viewModel, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ @@ -56,12 +53,5 @@ func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext { }, } - ctx.GetView().SetRenderSearchStatus(ctx.SearchTrait.RenderSearchStatus) - ctx.GetView().SetOnSelectItem(ctx.OnSearchSelect) - return ctx } - -func (self *WorkingTreeContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition { - return nil -} diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 1412254e7..7d677b756 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -152,7 +152,8 @@ func (self *CommitFilesController) GetOnRenderToMain() func() { from, to := self.context().GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) - cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, node.GetPath(), false) + paths := self.pathsForDiff(node) + cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, false) task := types.NewRunPtyTask(cmdObj.GetCmd()) self.c.RenderToMainViews(types.RefreshMainOpts{ @@ -171,7 +172,7 @@ func (self *CommitFilesController) copyDiffToClipboard(path string, toastMessage from, to := self.context().GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) - cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, path, true) + cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, []string{path}, true) diff, err := cmdObj.RunWithOutput() if err != nil { return err @@ -571,6 +572,21 @@ func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName } } +// pathsForDiff returns the file paths to use for a diff command. When a text +// filter is active and the node is a directory, only the visible (filtered) +// file paths are returned so the diff reflects what the user sees. +func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) []string { + if !node.IsFile() && self.context().IsFiltering() { + var paths []string + _ = node.ForEachFile(func(file *models.CommitFile) error { + paths = append(paths, file.Path) + return nil + }) + return paths + } + return []string{node.GetPath()} +} + // NOTE: these functions are identical to those in files_controller.go (except for types) and // could also be cleaned up with some generics func normalisedSelectedCommitFileNodes(selectedNodes []*filetree.CommitFileNode) []*filetree.CommitFileNode { diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 5b6cbcef2..8cc2ca5e2 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -294,7 +294,8 @@ func (self *FilesController) GetOnRenderToMain() func() { split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges()) mainShowsStaged := !split && node.GetHasStagedChanges() - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged) + pathOverrides := self.pathOverridesForDiff(node) + cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides) title := self.c.Tr.UnstagedChanges if mainShowsStaged { title = self.c.Tr.StagedChanges @@ -309,7 +310,7 @@ func (self *FilesController) GetOnRenderToMain() func() { } if split { - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true) + cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides) title := self.c.Tr.StagedChanges if mainShowsStaged { @@ -434,7 +435,19 @@ func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) e } } + // When filtering, expand directory nodes to individual visible file paths + // so that only filtered files are staged/unstaged. toPaths := func(nodes []*filetree.FileNode) []string { + if self.context().IsFiltering() { + var paths []string + for _, node := range nodes { + _ = node.ForEachFile(func(file *models.File) error { + paths = append(paths, file.Path) + return nil + }) + } + return paths + } return lo.Map(nodes, func(node *filetree.FileNode, _ int) string { return node.GetPath() }) @@ -469,22 +482,29 @@ func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) e return err } - // need to partition the paths into tracked and untracked (where we assume directories are tracked). Then we'll run the commands separately. - trackedNodes, untrackedNodes := utils.Partition(selectedNodes, func(node *filetree.FileNode) bool { - // We treat all directories as tracked. I'm not actually sure why we do this but - // it's been the existing behaviour for a while and nobody has complained - return !node.IsFile() || node.GetIsTracked() - }) - - if len(untrackedNodes) > 0 { - if err := self.c.Git().WorkingTree.UnstageUntrackedFiles(toPaths(untrackedNodes)); err != nil { + if self.context().IsFiltering() { + // When filtering, only unstage visible files + if err := self.unstageFilteredFiles(selectedNodes); err != nil { return err } - } + } else { + // need to partition the paths into tracked and untracked (where we assume directories are tracked). Then we'll run the commands separately. + trackedNodes, untrackedNodes := utils.Partition(selectedNodes, func(node *filetree.FileNode) bool { + // We treat all directories as tracked. I'm not actually sure why we do this but + // it's been the existing behaviour for a while and nobody has complained + return !node.IsFile() || node.GetIsTracked() + }) - if len(trackedNodes) > 0 { - if err := self.c.Git().WorkingTree.UnstageTrackedFiles(toPaths(trackedNodes)); err != nil { - return err + if len(untrackedNodes) > 0 { + if err := self.c.Git().WorkingTree.UnstageUntrackedFiles(toPaths(untrackedNodes)); err != nil { + return err + } + } + + if len(trackedNodes) > 0 { + if err := self.c.Git().WorkingTree.UnstageTrackedFiles(toPaths(trackedNodes)); err != nil { + return err + } } } } @@ -503,6 +523,48 @@ func (self *FilesController) press(nodes []*filetree.FileNode) error { return nil } +// pathOverridesForDiff returns file paths to override the node's path in diff +// commands when a text filter is active and the node is a directory. This +// ensures the diff only shows filtered/visible files. +func (self *FilesController) pathOverridesForDiff(node *filetree.FileNode) []string { + if !node.IsFile() && self.context().IsFiltering() { + var paths []string + _ = node.ForEachFile(func(file *models.File) error { + paths = append(paths, file.Path) + return nil + }) + return paths + } + return nil +} + +// unstageFilteredFiles unstages only the visible (filtered) files from the +// given nodes, correctly partitioning by tracked/untracked. +func (self *FilesController) unstageFilteredFiles(nodes []*filetree.FileNode) error { + var trackedPaths, untrackedPaths []string + for _, node := range nodes { + _ = node.ForEachFile(func(file *models.File) error { + if file.Tracked || file.HasStagedChanges { + trackedPaths = append(trackedPaths, file.Path) + } else { + untrackedPaths = append(untrackedPaths, file.Path) + } + return nil + }) + } + if len(untrackedPaths) > 0 { + if err := self.c.Git().WorkingTree.UnstageUntrackedFiles(untrackedPaths); err != nil { + return err + } + } + if len(trackedPaths) > 0 { + if err := self.c.Git().WorkingTree.UnstageTrackedFiles(trackedPaths); err != nil { + return err + } + } + return nil +} + func (self *FilesController) Context() types.Context { return self.context() } @@ -648,9 +710,21 @@ func (self *FilesController) toggleStagedAllWithLock() error { return err } - onlyTrackedFiles := self.context().GetStatusFilter() == filetree.DisplayTracked - if err := self.c.Git().WorkingTree.StageAll(onlyTrackedFiles); err != nil { - return err + if self.context().IsFiltering() { + // When filtering, only stage visible files + var paths []string + _ = root.ForEachFile(func(file *models.File) error { + paths = append(paths, file.Path) + return nil + }) + if err := self.c.Git().WorkingTree.StageFiles(paths, nil); err != nil { + return err + } + } else { + onlyTrackedFiles := self.context().GetStatusFilter() == filetree.DisplayTracked + if err := self.c.Git().WorkingTree.StageAll(onlyTrackedFiles); err != nil { + return err + } } } else { self.c.LogAction(self.c.Tr.Actions.UnstageAllFiles) @@ -659,8 +733,15 @@ func (self *FilesController) toggleStagedAllWithLock() error { return err } - if err := self.c.Git().WorkingTree.UnstageAll(); err != nil { - return err + if self.context().IsFiltering() { + // When filtering, only unstage visible files + if err := self.unstageFilteredFiles([]*filetree.FileNode{root}); err != nil { + return err + } + } else { + if err := self.c.Git().WorkingTree.UnstageAll(); err != nil { + return err + } } } diff --git a/pkg/gui/controllers/submodules_controller.go b/pkg/gui/controllers/submodules_controller.go index a4a1cb796..c0f52bed1 100644 --- a/pkg/gui/controllers/submodules_controller.go +++ b/pkg/gui/controllers/submodules_controller.go @@ -125,7 +125,7 @@ func (self *SubmodulesController) GetOnRenderToMain() func() { if file == nil { task = types.NewRenderStringTask(prefix) } else { - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges) + cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, nil) task = types.NewRunCommandTaskWithPrefix(cmdObj.GetCmd(), prefix) } } diff --git a/pkg/gui/controllers/switch_to_diff_files_controller.go b/pkg/gui/controllers/switch_to_diff_files_controller.go index 86e927558..cdf943cfe 100644 --- a/pkg/gui/controllers/switch_to_diff_files_controller.go +++ b/pkg/gui/controllers/switch_to_diff_files_controller.go @@ -80,12 +80,12 @@ func (self *SwitchToDiffFilesController) enter() error { } } + commitFilesContext.ClearFilter() commitFilesContext.ReInit(ref, refsRange) commitFilesContext.SetSelection(0) commitFilesContext.SetCanRebase(canRebase) commitFilesContext.SetParentContext(self.context) commitFilesContext.SetWindowName(self.context.GetWindowName()) - commitFilesContext.ClearSearchString() commitFilesContext.GetView().TitlePrefix = self.context.GetView().TitlePrefix self.c.Refresh(types.RefreshOptions{ diff --git a/pkg/gui/filetree/commit_file_tree.go b/pkg/gui/filetree/commit_file_tree.go index 7af83176f..bf6d1251c 100644 --- a/pkg/gui/filetree/commit_file_tree.go +++ b/pkg/gui/filetree/commit_file_tree.go @@ -15,6 +15,8 @@ type ICommitFileTree interface { GetAllItems() []*CommitFileNode GetAllFiles() []*models.CommitFile GetRoot() *CommitFileNode + SetTextFilter(filter string, useFuzzySearch bool) + GetTextFilter() string } type CommitFileTree struct { @@ -23,6 +25,8 @@ type CommitFileTree struct { showTree bool common *common.Common collapsedPaths *CollapsedPaths + textFilter string + useFuzzySearch bool } func (self *CommitFileTree) CollapseAll() { @@ -93,15 +97,34 @@ func (self *CommitFileTree) GetAllFiles() []*models.CommitFile { return self.getFiles() } +func (self *CommitFileTree) getFilesForDisplay() []*models.CommitFile { + files := self.getFiles() + if self.textFilter != "" { + files = filterCommitFilesByText(files, self.textFilter, self.useFuzzySearch) + } + return files +} + func (self *CommitFileTree) SetTree() { + filesForDisplay := self.getFilesForDisplay() showRootItem := self.common.UserConfig().Gui.ShowRootItemInFileTree if self.showTree { - self.tree = BuildTreeFromCommitFiles(self.getFiles(), showRootItem) + self.tree = BuildTreeFromCommitFiles(filesForDisplay, showRootItem) } else { - self.tree = BuildFlatTreeFromCommitFiles(self.getFiles(), showRootItem) + self.tree = BuildFlatTreeFromCommitFiles(filesForDisplay, showRootItem) } } +func (self *CommitFileTree) SetTextFilter(filter string, useFuzzySearch bool) { + self.textFilter = filter + self.useFuzzySearch = useFuzzySearch + self.SetTree() +} + +func (self *CommitFileTree) GetTextFilter() string { + return self.textFilter +} + func (self *CommitFileTree) IsCollapsed(path string) bool { return self.collapsedPaths.IsCollapsed(path) } diff --git a/pkg/gui/filetree/commit_file_tree_view_model.go b/pkg/gui/filetree/commit_file_tree_view_model.go index 02b0fff9a..c2e7e74e4 100644 --- a/pkg/gui/filetree/commit_file_tree_view_model.go +++ b/pkg/gui/filetree/commit_file_tree_view_model.go @@ -8,6 +8,8 @@ import ( "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/gui/context/traits" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/i18n" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -39,6 +41,8 @@ type CommitFileTreeViewModel struct { // we set this to true when you're viewing the files within the checked-out branch's commits. // If you're viewing the files of some random other branch we can't do any rebase stuff. canRebase bool + + searchHistory *utils.HistoryBuffer[string] } var _ ICommitFileTreeViewModel = &CommitFileTreeViewModel{} @@ -52,6 +56,7 @@ func NewCommitFileTreeViewModel(getFiles func() []*models.CommitFile, common *co ref: nil, refRange: nil, canRebase: false, + searchHistory: utils.NewHistoryBuffer[string](1000), } } @@ -203,3 +208,51 @@ func (self *CommitFileTreeViewModel) SelectPath(filepath string, showRootItem bo self.SetSelection(index) } } + +// IFilterableContext methods + +func (self *CommitFileTreeViewModel) SetFilter(filter string, useFuzzySearch bool) { + self.ICommitFileTree.SetTextFilter(filter, useFuzzySearch) +} + +func (self *CommitFileTreeViewModel) GetFilter() string { + return self.ICommitFileTree.GetTextFilter() +} + +func (self *CommitFileTreeViewModel) ClearFilter() { + selectedNode := self.GetSelected() + var selectedPath string + if selectedNode != nil { + selectedPath = selectedNode.GetInternalPath() + } + + self.ICommitFileTree.SetTextFilter("", false) + + if selectedPath != "" { + self.ExpandToPath(selectedPath) + if idx, found := self.GetIndexForPath(selectedPath); found { + self.SetSelection(idx) + return + } + } + self.ClampSelection() +} + +func (self *CommitFileTreeViewModel) ReApplyFilter(useFuzzySearch bool) { + self.ICommitFileTree.SetTextFilter(self.ICommitFileTree.GetTextFilter(), useFuzzySearch) +} + +func (self *CommitFileTreeViewModel) IsFiltering() bool { + return self.ICommitFileTree.GetTextFilter() != "" +} + +// used for type switch +func (self *CommitFileTreeViewModel) IsFilterableContext() {} + +func (self *CommitFileTreeViewModel) FilterPrefix(tr *i18n.TranslationSet) string { + return tr.FilterPrefix +} + +func (self *CommitFileTreeViewModel) GetSearchHistory() *utils.HistoryBuffer[string] { + return self.searchHistory +} diff --git a/pkg/gui/filetree/file_filter.go b/pkg/gui/filetree/file_filter.go new file mode 100644 index 000000000..cb4916cf7 --- /dev/null +++ b/pkg/gui/filetree/file_filter.go @@ -0,0 +1,48 @@ +package filetree + +import ( + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/sahilm/fuzzy" + "github.com/samber/lo" +) + +type filePathSource struct { + files []*models.File +} + +func (s *filePathSource) String(i int) string { + return s.files[i].Path +} + +func (s *filePathSource) Len() int { + return len(s.files) +} + +func filterFilesByText(files []*models.File, filter string, useFuzzySearch bool) []*models.File { + source := &filePathSource{files: files} + matches := utils.FindFrom(filter, source, useFuzzySearch) + return lo.Map(matches, func(match fuzzy.Match, _ int) *models.File { + return files[match.Index] + }) +} + +type commitFilePathSource struct { + files []*models.CommitFile +} + +func (s *commitFilePathSource) String(i int) string { + return s.files[i].Path +} + +func (s *commitFilePathSource) Len() int { + return len(s.files) +} + +func filterCommitFilesByText(files []*models.CommitFile, filter string, useFuzzySearch bool) []*models.CommitFile { + source := &commitFilePathSource{files: files} + matches := utils.FindFrom(filter, source, useFuzzySearch) + return lo.Map(matches, func(match fuzzy.Match, _ int) *models.CommitFile { + return files[match.Index] + }) +} diff --git a/pkg/gui/filetree/file_tree.go b/pkg/gui/filetree/file_tree.go index 8190c379a..9840fd8dd 100644 --- a/pkg/gui/filetree/file_tree.go +++ b/pkg/gui/filetree/file_tree.go @@ -48,6 +48,8 @@ type IFileTree interface { GetAllFiles() []*models.File GetStatusFilter() FileTreeDisplayFilter GetRoot() *FileNode + SetTextFilter(filter string, useFuzzySearch bool) + GetTextFilter() string } type FileTree struct { @@ -57,6 +59,8 @@ type FileTree struct { common *common.Common filter FileTreeDisplayFilter collapsedPaths *CollapsedPaths + textFilter string + useFuzzySearch bool } var _ IFileTree = &FileTree{} @@ -80,24 +84,31 @@ func (self *FileTree) ExpandToPath(path string) { } func (self *FileTree) getFilesForDisplay() []*models.File { + var files []*models.File switch self.filter { case DisplayAll: - return self.getFiles() + files = self.getFiles() case DisplayStaged: - return self.FilterFiles(func(file *models.File) bool { return file.HasStagedChanges }) + files = self.FilterFiles(func(file *models.File) bool { return file.HasStagedChanges }) case DisplayUnstaged: - return self.FilterFiles(func(file *models.File) bool { return file.HasUnstagedChanges }) + files = self.FilterFiles(func(file *models.File) bool { return file.HasUnstagedChanges }) case DisplayTracked: // untracked but staged files are technically not tracked by git // but including such files in the filtered mode helps see what files are getting committed - return self.FilterFiles(func(file *models.File) bool { return file.Tracked || file.HasStagedChanges }) + files = self.FilterFiles(func(file *models.File) bool { return file.Tracked || file.HasStagedChanges }) case DisplayUntracked: - return self.FilterFiles(func(file *models.File) bool { return !(file.Tracked || file.HasStagedChanges) }) + files = self.FilterFiles(func(file *models.File) bool { return !(file.Tracked || file.HasStagedChanges) }) case DisplayConflicted: - return self.FilterFiles(func(file *models.File) bool { return file.HasMergeConflicts }) + files = self.FilterFiles(func(file *models.File) bool { return file.HasMergeConflicts }) default: panic(fmt.Sprintf("Unexpected files display filter: %d", self.filter)) } + + if self.textFilter != "" { + files = filterFilesByText(files, self.textFilter, self.useFuzzySearch) + } + + return files } func (self *FileTree) ForceShowUntracked() bool { @@ -213,3 +224,13 @@ func (self *FileTree) CollapsedPaths() *CollapsedPaths { func (self *FileTree) GetStatusFilter() FileTreeDisplayFilter { return self.filter } + +func (self *FileTree) SetTextFilter(filter string, useFuzzySearch bool) { + self.textFilter = filter + self.useFuzzySearch = useFuzzySearch + self.SetTree() +} + +func (self *FileTree) GetTextFilter() string { + return self.textFilter +} diff --git a/pkg/gui/filetree/file_tree_view_model.go b/pkg/gui/filetree/file_tree_view_model.go index 3db39d0a1..741550c19 100644 --- a/pkg/gui/filetree/file_tree_view_model.go +++ b/pkg/gui/filetree/file_tree_view_model.go @@ -8,6 +8,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/gui/context/traits" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -24,6 +25,7 @@ type FileTreeViewModel struct { sync.RWMutex types.IListCursor IFileTree + searchHistory *utils.HistoryBuffer[string] } var _ IFileTreeViewModel = &FileTreeViewModel{} @@ -32,8 +34,9 @@ func NewFileTreeViewModel(getFiles func() []*models.File, common *common.Common, fileTree := NewFileTree(getFiles, common, showTree) listCursor := traits.NewListCursor(fileTree.Len) return &FileTreeViewModel{ - IFileTree: fileTree, - IListCursor: listCursor, + IFileTree: fileTree, + IListCursor: listCursor, + searchHistory: utils.NewHistoryBuffer[string](1000), } } @@ -220,3 +223,51 @@ func (self *FileTreeViewModel) ExpandAll() { self.SetSelectedLineIdx(index) } } + +// IFilterableContext methods + +func (self *FileTreeViewModel) SetFilter(filter string, useFuzzySearch bool) { + self.IFileTree.SetTextFilter(filter, useFuzzySearch) +} + +func (self *FileTreeViewModel) GetFilter() string { + return self.IFileTree.GetTextFilter() +} + +func (self *FileTreeViewModel) ClearFilter() { + selectedNode := self.GetSelected() + var selectedPath string + if selectedNode != nil { + selectedPath = selectedNode.GetInternalPath() + } + + self.IFileTree.SetTextFilter("", false) + + if selectedPath != "" { + self.ExpandToPath(selectedPath) + if idx, found := self.GetIndexForPath(selectedPath); found { + self.SetSelection(idx) + return + } + } + self.ClampSelection() +} + +func (self *FileTreeViewModel) ReApplyFilter(useFuzzySearch bool) { + self.IFileTree.SetTextFilter(self.IFileTree.GetTextFilter(), useFuzzySearch) +} + +func (self *FileTreeViewModel) IsFiltering() bool { + return self.IFileTree.GetTextFilter() != "" +} + +// used for type switch +func (self *FileTreeViewModel) IsFilterableContext() {} + +func (self *FileTreeViewModel) FilterPrefix(tr *i18n.TranslationSet) string { + return tr.FilterPrefix +} + +func (self *FileTreeViewModel) GetSearchHistory() *utils.HistoryBuffer[string] { + return self.searchHistory +} diff --git a/pkg/integration/tests/filter_and_search/filter_commit_files.go b/pkg/integration/tests/filter_and_search/filter_commit_files.go index 953eaf34d..a1a39f1f4 100644 --- a/pkg/integration/tests/filter_and_search/filter_commit_files.go +++ b/pkg/integration/tests/filter_and_search/filter_commit_files.go @@ -8,7 +8,7 @@ import ( var FilterCommitFiles = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Basic commit file filtering by text", ExtraCmdArgs: []string{}, - Skip: true, // skipping until we have implemented file view filtering + Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateDir("folder1") diff --git a/pkg/integration/tests/filter_and_search/filter_commit_files_toggle_directory.go b/pkg/integration/tests/filter_and_search/filter_commit_files_toggle_directory.go new file mode 100644 index 000000000..174c91fc4 --- /dev/null +++ b/pkg/integration/tests/filter_and_search/filter_commit_files_toggle_directory.go @@ -0,0 +1,61 @@ +package filter_and_search + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FilterCommitFilesToggleDirectory = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Toggle a filtered directory for a custom patch only adds visible files", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateDir("dir1") + shell.CreateFileAndAdd("dir1/apple-grape", "apple-grape content\n") + shell.CreateFileAndAdd("dir1/apple-orange", "apple-orange content\n") + shell.CreateFileAndAdd("dir1/grape-orange", "grape-orange content\n") + shell.Commit("first commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("first commit").IsSelected(), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Equals("▼ dir1").IsSelected(), + Equals(" A apple-grape"), + Equals(" A apple-orange"), + Equals(" A grape-orange"), + ). + // Filter to show only "apple" files (staying in tree view) + FilterOrSearch("apple"). + Lines( + // first item is always selected after filtering + Equals("▼ dir1").IsSelected(), + Equals(" A apple-grape"), + Equals(" A apple-orange"), + ). + // dir1 is already selected; toggle for patch + PressPrimaryAction(). + Lines( + Equals("▼ dir1").IsSelected(), + Equals(" ● apple-grape"), + Equals(" ● apple-orange"), + ) + + t.Views().Information().Content(Contains("Building patch")) + + // Verify only the filtered files are in the patch (not grape-orange) + t.Views().Secondary().Content( + Contains("apple-grape"). + Contains("apple-orange"). + DoesNotContain("grape-orange"), + ) + }, +}) diff --git a/pkg/integration/tests/filter_and_search/filter_files.go b/pkg/integration/tests/filter_and_search/filter_files.go index 6eae90c18..5a029b146 100644 --- a/pkg/integration/tests/filter_and_search/filter_files.go +++ b/pkg/integration/tests/filter_and_search/filter_files.go @@ -8,7 +8,7 @@ import ( var FilterFiles = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Basic file filtering by text", ExtraCmdArgs: []string{}, - Skip: true, // Skipping until we have implemented file view filtering + Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateDir("folder1") diff --git a/pkg/integration/tests/filter_and_search/filter_files_stage_all.go b/pkg/integration/tests/filter_and_search/filter_files_stage_all.go new file mode 100644 index 000000000..16c981b0c --- /dev/null +++ b/pkg/integration/tests/filter_and_search/filter_files_stage_all.go @@ -0,0 +1,50 @@ +package filter_and_search + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FilterFilesStageAll = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Toggle all staging with a filter only stages visible files", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateDir("dir1") + shell.CreateFile("dir1/apple-grape", "apple-grape content\n") + shell.CreateFile("dir1/apple-orange", "apple-orange content\n") + shell.CreateFile("dir1/grape-orange", "grape-orange content\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + Focus(). + Lines( + Equals("▼ dir1").IsSelected(), + Equals(" ?? apple-grape"), + Equals(" ?? apple-orange"), + Equals(" ?? grape-orange"), + ). + // Filter to show only "apple" files + FilterOrSearch("apple"). + Lines( + // first item is always selected after filtering + Equals("▼ dir1").IsSelected(), + Equals(" ?? apple-grape"), + Equals(" ?? apple-orange"), + ). + // Stage all visible files + Press(keys.Files.ToggleStagedAll). + // Clear the filter and verify only apple files are staged + PressEscape() + + t.Views().Files(). + IsFocused(). + Lines( + Equals("▼ dir1").IsSelected(), + Equals(" A apple-grape"), + Equals(" A apple-orange"), + Equals(" ?? grape-orange"), + ) + }, +}) diff --git a/pkg/integration/tests/filter_and_search/filter_files_stage_directory.go b/pkg/integration/tests/filter_and_search/filter_files_stage_directory.go new file mode 100644 index 000000000..3126e5d6f --- /dev/null +++ b/pkg/integration/tests/filter_and_search/filter_files_stage_directory.go @@ -0,0 +1,50 @@ +package filter_and_search + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FilterFilesStageDirectory = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Staging a filtered directory only stages visible files", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateDir("dir1") + shell.CreateFile("dir1/apple-grape", "apple-grape content\n") + shell.CreateFile("dir1/apple-orange", "apple-orange content\n") + shell.CreateFile("dir1/grape-orange", "grape-orange content\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + Focus(). + Lines( + Equals("▼ dir1").IsSelected(), + Equals(" ?? apple-grape"), + Equals(" ?? apple-orange"), + Equals(" ?? grape-orange"), + ). + // Filter to show only "apple" files + FilterOrSearch("apple"). + Lines( + // first item is always selected after filtering + Equals("▼ dir1").IsSelected(), + Equals(" ?? apple-grape"), + Equals(" ?? apple-orange"), + ). + // dir1 is already selected; stage it + PressPrimaryAction(). + // Clear the filter to see all files and verify only apple files are staged + PressEscape() + + t.Views().Files(). + IsFocused(). + Lines( + Equals("▼ dir1"), + Equals(" A apple-grape"), + Equals(" A apple-orange"), + Equals(" ?? grape-orange"), + ) + }, +}) diff --git a/pkg/integration/tests/filter_and_search/nested_filter.go b/pkg/integration/tests/filter_and_search/nested_filter.go index 703c7ccf1..6ccb606f2 100644 --- a/pkg/integration/tests/filter_and_search/nested_filter.go +++ b/pkg/integration/tests/filter_and_search/nested_filter.go @@ -70,10 +70,7 @@ var NestedFilter = NewIntegrationTest(NewIntegrationTestArgs{ ). FilterOrSearch("grape"). Lines( - Equals("▼ /"), - Equals(" A apple"), - Equals(" A grape").IsSelected(), - Equals(" A orange"), + Equals("A grape").IsSelected(), ). PressEnter() @@ -91,15 +88,12 @@ var NestedFilter = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().CommitFiles(). IsFocused(). Lines( - Equals("▼ /"), - Equals(" A apple"), - Equals(" A grape").IsSelected(), - Equals(" A orange"), + Equals("A grape").IsSelected(), ). Tap(func() { t.Views().Search().IsVisible().Content(Contains("matches for 'grape'")) }). - // cancel search + // cancel filter PressEscape(). Tap(func() { t.Views().Search().IsInvisible() diff --git a/pkg/integration/tests/filter_and_search/nested_filter_transient.go b/pkg/integration/tests/filter_and_search/nested_filter_transient.go index 8548d68c0..6af0a904e 100644 --- a/pkg/integration/tests/filter_and_search/nested_filter_transient.go +++ b/pkg/integration/tests/filter_and_search/nested_filter_transient.go @@ -75,9 +75,7 @@ var NestedFilterTransient = NewIntegrationTest(NewIntegrationTestArgs{ ). FilterOrSearch("two"). Lines( - Equals("▼ /"), - Equals(" A file-one"), - Equals(" A file-two").IsSelected(), + Equals("A file-two").IsSelected(), ) t.Views().Branches(). @@ -96,7 +94,7 @@ var NestedFilterTransient = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().CommitFiles(). IsFocused(). - // the search on the commit-files context has been cancelled + // the filter on the commit-files context has been cancelled Lines( Equals("▼ /").IsSelected(), Equals(" A file-one"), diff --git a/pkg/integration/tests/patch_building/toggle_directory.go b/pkg/integration/tests/patch_building/toggle_directory.go new file mode 100644 index 000000000..c9161fe69 --- /dev/null +++ b/pkg/integration/tests/patch_building/toggle_directory.go @@ -0,0 +1,64 @@ +package patch_building + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ToggleDirectory = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Toggle a directory for a custom patch", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateDir("dir1") + shell.CreateFileAndAdd("dir1/file1", "file1 content\n") + shell.CreateFileAndAdd("dir1/file2", "file2 content\n") + shell.CreateFileAndAdd("dir1/file3", "file3 content\n") + shell.CreateFileAndAdd("other-file", "other content\n") + shell.Commit("first commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("first commit").IsSelected(), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Equals("▼ /").IsSelected(), + Equals(" ▼ dir1"), + Equals(" A file1"), + Equals(" A file2"), + Equals(" A file3"), + Equals(" A other-file"), + ). + NavigateToLine(Contains("dir1")). + PressPrimaryAction(). + Lines( + Equals("▼ /"), + Equals(" ▼ dir1").IsSelected(), + Equals(" ● file1"), + Equals(" ● file2"), + Equals(" ● file3"), + Equals(" A other-file"), + ) + + t.Views().Information().Content(Contains("Building patch")) + + // Toggle the directory again to remove all files from the patch + t.Views().CommitFiles(). + PressPrimaryAction(). + Lines( + Equals("▼ /"), + Equals(" ▼ dir1").IsSelected(), + Equals(" A file1"), + Equals(" A file2"), + Equals(" A file3"), + Equals(" A other-file"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index b98de18d8..1fdd4c161 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -232,7 +232,10 @@ var tests = []*components.IntegrationTest{ file.StageRangeSelect, filter_and_search.FilterByFileStatus, filter_and_search.FilterCommitFiles, + filter_and_search.FilterCommitFilesToggleDirectory, filter_and_search.FilterFiles, + filter_and_search.FilterFilesStageAll, + filter_and_search.FilterFilesStageDirectory, filter_and_search.FilterFuzzy, filter_and_search.FilterMenu, filter_and_search.FilterMenuByKeybinding, @@ -355,6 +358,7 @@ var tests = []*components.IntegrationTest{ patch_building.SelectAllFiles, patch_building.SpecificSelection, patch_building.StartNewPatch, + patch_building.ToggleDirectory, patch_building.ToggleRange, reflog.Checkout, reflog.CherryPick,