Stage a selection spanning several files from the focused main view

The focused main view of a directory node shows a multi-file diff, so a range
selected in it can span more than one file. Group the selected change lines by
file and apply one patch per file, logging the action and refreshing once around
the whole batch. The stage/unstage direction is uniform — the whole diff is
rendered on one side — so it's decided once for the batch.

Each change line is mapped back to its file by the path its diff-line metadata
carries, which also lets a single-file selection go through the same path
instead of relying on the selected node being the file; staging from the focused
main view no longer bails on a directory selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-06-18 13:32:36 +02:00
parent ec1bdaafb3
commit a5a0506913
3 changed files with 96 additions and 21 deletions

View file

@ -465,9 +465,7 @@ func (self *FilesController) GetOnStageFocusedMainView() func(mainViewName strin
}
node := self.context().GetSelected()
if node == nil || !node.IsFile() {
// Staging from a multi-file (directory) diff is a later step; for now
// only single-file diffs are stageable from the focused main view.
if node == nil {
return nil
}
@ -476,22 +474,51 @@ func (self *FilesController) GetOnStageFocusedMainView() func(mainViewName strin
return nil
}
// The staged diff is shown in the secondary half of a split, and in the main
// half when the file has only staged changes; in those cases space unstages,
// otherwise it stages.
// The whole diff shown in the main view is on one side — the staged diff in
// the secondary half of a split, and in the main half when there are only
// staged changes; in those cases space unstages, otherwise it stages. The
// direction is the same for every file in a multi-file (directory) diff.
_, mainShowsStaged := self.diffSplitState(node)
staged := mainShowsStaged || mainViewName == self.c.Contexts().NormalSecondary.GetViewName()
reverse := mainShowsStaged || mainViewName == self.c.Contexts().NormalSecondary.GetViewName()
return self.stageDiffLines(node.File, infos, staged)
// A directory diff spans several files; group the selected change lines by
// file and apply one patch per file.
infosByFile := lo.GroupBy(infos, func(info types.DiffLineInfo) string { return info.Path })
self.c.LogAction(self.c.Tr.Actions.ApplyPatch)
for path, fileInfos := range infosByFile {
file := self.fileForDiffLinePath(path)
if file == nil {
continue
}
if err := self.stageDiffLines(file, fileInfos, reverse); err != nil {
return err
}
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}})
return nil
}
}
// fileForDiffLinePath maps a diff line's absolute file path (as carried by the
// diff-line metadata) to the working-tree file it belongs to, or nil if it isn't a
// tracked working-tree file.
func (self *FilesController) fileForDiffLinePath(path string) *models.File {
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path)
if err != nil {
return nil
}
return self.context().FileTreeViewModel.GetFile(filepath.ToSlash(relativePath))
}
// stageDiffLines stages, or when reverse is true unstages, the diff lines identified
// by infos (a single line, a range, or a hunk). It builds one patch from the file's
// diff including all the selected change lines and applies it the same way the
// staging view does, but identifies the patch lines from the diff-line metadata
// rather than from a patch-explorer selection. A selection covering no change lines
// yields an empty patch and is a no-op.
// by infos (a single line, a range, or a hunk) — all belonging to file. It builds one
// patch from the file's diff including all the selected change lines and applies it
// the same way the staging view does, but identifies the patch lines from the
// diff-line metadata rather than from a patch-explorer selection. A selection
// covering no change lines yields an empty patch and is a no-op. The caller logs the
// action and refreshes, once, around the (possibly several) files it stages.
//
// Each selected change line is keyed by its (file line number, deletion?) identity,
// and the freshly parsed patch is scanned for the body lines matching those
@ -540,16 +567,10 @@ func (self *FilesController) stageDiffLines(file *models.File, infos []types.Dif
return nil
}
self.c.LogAction(self.c.Tr.Actions.ApplyPatch)
if err := self.c.Git().Patch.ApplyPatch(patchToApply, git_commands.ApplyPatchOpts{
return self.c.Git().Patch.ApplyPatch(patchToApply, git_commands.ApplyPatchOpts{
Reverse: reverse,
Cached: true,
}); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}})
return nil
})
}
// if we are dealing with a status for which there is no key in this map,

View file

@ -0,0 +1,53 @@
package staging
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var StageRangeSpanningFilesFromMainView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Select a range spanning two files in a directory's focused main view and stage it in one go",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("a", "a\n")
shell.CreateFileAndAdd("b", "b\n")
shell.Commit("one")
shell.UpdateFile("a", "a\nfromA\n")
shell.UpdateFile("b", "b\nfromB\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// The root node is selected, so the focused main view shows both files' diffs.
t.Views().Files().
IsFocused().
Lines(
Contains("▼ /").IsSelected(),
Contains(" M a"),
Contains(" M b"),
).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(
Contains("+fromA"),
).
// Select a range reaching from the addition in the first file into the
// addition in the second, then stage it.
Press(keys.Universal.ToggleRangeSelect).
NavigateToLine(Contains("+fromB")).
PressPrimaryAction().
Tap(func() {
// Both files' additions got staged in one go.
t.Views().Files().Lines(
Contains("▼ /"),
Contains("M a"),
Contains("M b"),
)
})
},
})

View file

@ -421,6 +421,7 @@ var tests = []*components.IntegrationTest{
staging.StagePartialBlockOfChangesLastLines,
staging.StagePartialBlockOfChangesMiddleLines,
staging.StageRangeFromMainView,
staging.StageRangeSpanningFilesFromMainView,
staging.StageRanges,
stash.Apply,
stash.ApplyPatch,