Only apply directory-level actions to visible files

This commit is contained in:
Jesse Duffield
2026-02-09 08:12:50 +11:00
parent 75488f1041
commit 7357c1daea
9 changed files with 373 additions and 63 deletions
+14 -6
View File
@@ -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()
+28 -36
View File
@@ -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
@@ -400,15 +401,12 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm
selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes)
// Collect all files to operate on. For directory nodes, this
// includes all files under the directory, not just the ones
// visible after filtering.
filesToProcess := self.collectFilesForNodes(selectedNodes)
// Find if any file in the selection is unselected or partially added
adding := lo.SomeBy(filesToProcess, func(file *models.CommitFile) bool {
fileStatus := self.c.Git().Patch.PatchBuilder.GetFileStatus(file.Path, self.context().GetRef().RefName())
return fileStatus == patch.PART || fileStatus == patch.UNSELECTED
adding := lo.SomeBy(selectedNodes, func(node *filetree.CommitFileNode) bool {
return node.SomeFile(func(file *models.CommitFile) bool {
fileStatus := self.c.Git().Patch.PatchBuilder.GetFileStatus(file.Path, self.context().GetRef().RefName())
return fileStatus == patch.PART || fileStatus == patch.UNSELECTED
})
})
patchOperationFunction := self.c.Git().Patch.PatchBuilder.RemoveFile
@@ -417,8 +415,11 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm
patchOperationFunction = self.c.Git().Patch.PatchBuilder.AddFileWhole
}
for _, file := range filesToProcess {
if err := patchOperationFunction(file.Path); err != nil {
for _, node := range selectedNodes {
err := node.ForEachFile(func(file *models.CommitFile) error {
return patchOperationFunction(file.Path)
})
if err != nil {
return err
}
}
@@ -550,6 +551,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 {
@@ -570,30 +586,6 @@ func isDescendentOfSelectedCommitFileNodes(node *filetree.CommitFileNode, select
return false
}
// collectFilesForNodes returns all commit files that should be operated on
// for the given nodes. For directory nodes, this includes all files under
// the directory from the full unfiltered list, so that toggling a directory
// for a patch always affects all files regardless of any active text filter.
func (self *CommitFilesController) collectFilesForNodes(nodes []*filetree.CommitFileNode) []*models.CommitFile {
allCommitFiles := self.context().GetAllFiles()
result := []*models.CommitFile{}
for _, node := range nodes {
if node.IsFile() {
result = append(result, node.File)
} else {
dirPath := node.GetPath()
for _, file := range allCommitFiles {
if dirPath == "" || strings.HasPrefix(file.Path, dirPath+"/") {
result = append(result, file)
}
}
}
}
return result
}
func (self *CommitFilesController) isInTreeMode() *types.DisabledReason {
if !self.context().CommitFileTreeViewModel.InTreeMode() {
return &types.DisabledReason{Text: self.c.Tr.DisabledInFlatView}
+101 -20
View File
@@ -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
}
}
}
+1 -1
View File
@@ -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)
}
}
@@ -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(
Contains("dir1").IsSelected(),
Contains("apple-grape"),
Contains("apple-orange"),
Contains("grape-orange"),
).
// Filter to show only "apple" files (staying in tree view)
FilterOrSearch("apple").
Lines(
// first item is always selected after filtering
Contains("dir1").IsSelected(),
Contains("apple-grape"),
Contains("apple-orange"),
).
// dir1 is already selected; toggle for patch
PressPrimaryAction().
Lines(
Contains("dir1").IsSelected(),
Contains("● apple-grape"),
Contains("● 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"),
)
},
})
@@ -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(
Contains("dir1").IsSelected(),
Contains("apple-grape"),
Contains("apple-orange"),
Contains("grape-orange"),
).
// Filter to show only "apple" files
FilterOrSearch("apple").
Lines(
// first item is always selected after filtering
Contains("dir1").IsSelected(),
Contains("apple-grape"),
Contains("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(
Contains("dir1").IsSelected(),
Contains("A apple-grape"),
Contains("A apple-orange"),
Contains("?? grape-orange"),
)
},
})
@@ -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(
Contains("dir1").IsSelected(),
Contains("apple-grape"),
Contains("apple-orange"),
Contains("grape-orange"),
).
// Filter to show only "apple" files
FilterOrSearch("apple").
Lines(
// first item is always selected after filtering
Contains("dir1").IsSelected(),
Contains("apple-grape"),
Contains("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(
Contains("dir1"),
Contains("A apple-grape"),
Contains("A apple-orange"),
Contains("?? grape-orange"),
)
},
})
@@ -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"),
)
},
})
+4
View File
@@ -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,
@@ -354,6 +357,7 @@ var tests = []*components.IntegrationTest{
patch_building.SelectAllFiles,
patch_building.SpecificSelection,
patch_building.StartNewPatch,
patch_building.ToggleDirectory,
patch_building.ToggleRange,
reflog.Checkout,
reflog.CherryPick,