jesseduffield.lazygit/pkg/gui/filetree/file_tree_view_model.go
Jesse Duffield 615c566ac4 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 <noreply@anthropic.com>
2026-03-07 20:04:31 +01:00

274 lines
7 KiB
Go

package filetree
import (
"strings"
"sync"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"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"
)
type IFileTreeViewModel interface {
IFileTree
types.IListCursor
}
// This combines our FileTree struct with a cursor that retains information about
// which item is selected. It also contains logic for repositioning that cursor
// after the files are refreshed
type FileTreeViewModel struct {
sync.RWMutex
types.IListCursor
IFileTree
searchHistory *utils.HistoryBuffer[string]
}
var _ IFileTreeViewModel = &FileTreeViewModel{}
func NewFileTreeViewModel(getFiles func() []*models.File, common *common.Common, showTree bool) *FileTreeViewModel {
fileTree := NewFileTree(getFiles, common, showTree)
listCursor := traits.NewListCursor(fileTree.Len)
return &FileTreeViewModel{
IFileTree: fileTree,
IListCursor: listCursor,
searchHistory: utils.NewHistoryBuffer[string](1000),
}
}
func (self *FileTreeViewModel) GetSelected() *FileNode {
if self.Len() == 0 {
return nil
}
return self.Get(self.GetSelectedLineIdx())
}
func (self *FileTreeViewModel) GetSelectedItemId() string {
item := self.GetSelected()
if item == nil {
return ""
}
return item.ID()
}
func (self *FileTreeViewModel) GetSelectedItems() ([]*FileNode, int, int) {
if self.Len() == 0 {
return nil, 0, 0
}
startIdx, endIdx := self.GetSelectionRange()
nodes := []*FileNode{}
for i := startIdx; i <= endIdx; i++ {
nodes = append(nodes, self.Get(i))
}
return nodes, startIdx, endIdx
}
func (self *FileTreeViewModel) GetSelectedItemIds() ([]string, int, int) {
selectedItems, startIdx, endIdx := self.GetSelectedItems()
ids := lo.Map(selectedItems, func(item *FileNode, _ int) string {
return item.ID()
})
return ids, startIdx, endIdx
}
func (self *FileTreeViewModel) GetSelectedFile() *models.File {
node := self.GetSelected()
if node == nil {
return nil
}
return node.File
}
func (self *FileTreeViewModel) GetSelectedPath() string {
node := self.GetSelected()
if node == nil {
return ""
}
return node.GetPath()
}
func (self *FileTreeViewModel) SetTree() {
newFiles := self.GetAllFiles()
selectedNode := self.GetSelected()
// for when you stage the old file of a rename and the new file is in a collapsed dir
for _, file := range newFiles {
if selectedNode != nil && selectedNode.path != "" && file.PreviousPath == selectedNode.path {
self.ExpandToPath(file.Path)
}
}
prevNodes := self.GetAllItems()
prevSelectedLineIdx := self.GetSelectedLineIdx()
self.IFileTree.SetTree()
if selectedNode != nil {
newNodes := self.GetAllItems()
newIdx := self.findNewSelectedIdx(prevNodes[prevSelectedLineIdx:], newNodes)
if newIdx != -1 && newIdx != prevSelectedLineIdx {
self.SetSelection(newIdx)
}
}
self.ClampSelection()
}
// Let's try to find our file again and move the cursor to that.
// If we can't find our file, it was probably just removed by the user. In that
// case, we go looking for where the next file has been moved to. Given that the
// user could have removed a whole directory, we continue iterating through the old
// nodes until we find one that exists in the new set of nodes, then move the cursor
// to that.
// prevNodes starts from our previously selected node because we don't need to consider anything above that
func (self *FileTreeViewModel) findNewSelectedIdx(prevNodes []*FileNode, currNodes []*FileNode) int {
getPaths := func(node *FileNode) []string {
if node == nil {
return nil
}
if node.File != nil && node.File.IsRename() {
return node.File.Names()
}
return []string{node.path}
}
for _, prevNode := range prevNodes {
selectedPaths := getPaths(prevNode)
for idx, node := range currNodes {
paths := getPaths(node)
// If you started off with a rename selected, and now it's broken in two, we want you to jump to the new file, not the old file.
// This is because the new should be in the same position as the rename was meaning less cursor jumping
foundOldFileInRename := prevNode.File != nil && prevNode.File.IsRename() && node.path == prevNode.File.PreviousPath
foundNode := utils.StringArraysOverlap(paths, selectedPaths) && !foundOldFileInRename
if foundNode {
return idx
}
}
}
return -1
}
func (self *FileTreeViewModel) SetStatusFilter(filter FileTreeDisplayFilter) {
self.IFileTree.SetStatusFilter(filter)
self.IListCursor.SetSelection(0)
}
// If we're going from flat to tree we want to select the same file.
// If we're going from tree to flat and we have a file selected we want to select that.
// If instead we've selected a directory we need to select the first file in that directory.
func (self *FileTreeViewModel) ToggleShowTree() {
selectedNode := self.GetSelected()
self.IFileTree.ToggleShowTree()
if selectedNode == nil {
return
}
path := selectedNode.path
if self.InTreeMode() {
self.ExpandToPath(path)
} else if len(selectedNode.Children) > 0 {
path = selectedNode.GetLeaves()[0].path
}
index, found := self.GetIndexForPath(path)
if found {
self.SetSelectedLineIdx(index)
}
}
func (self *FileTreeViewModel) CollapseAll() {
selectedNode := self.GetSelected()
self.IFileTree.CollapseAll()
if selectedNode == nil {
return
}
topLevelPath := strings.Split(selectedNode.path, "/")[0]
index, found := self.GetIndexForPath(topLevelPath)
if found {
self.SetSelectedLineIdx(index)
}
}
func (self *FileTreeViewModel) ExpandAll() {
selectedNode := self.GetSelected()
self.IFileTree.ExpandAll()
if selectedNode == nil {
return
}
index, found := self.GetIndexForPath(selectedNode.path)
if found {
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
}