jesseduffield.lazygit/pkg/gui/controllers/switch_to_diff_files_controller.go
Stefan Haller 88dae2f2c6 Collapse the focused-main-view handler channels into one
Each command a side panel handles in the focused main view needed its own
delegation channel: a HasKeybindings getter, an IBaseContext Add method, a
BaseContext field + getter, a baseController nil default, and an attach.go
registration — roughly five touch points per command. With three commands
(click, stage, toggle-patch) that was already a lot of boilerplate, and it
doesn't scale to the discard / copy commands coming next.

Replace the three channels with one: a side panel exposes a single
FocusedMainViewActions interface via GetFocusedMainViewActions (nil when its
diff offers no actions), and the controllers implement that interface
directly. The stage and toggle-patch handlers, already unified to a plain
error return, become one PrimaryAction method whose meaning is the panel's
business (stage for the files panel, patch toggle for the commit panels);
the click handler becomes OnClick. MainViewController is now a thin
dispatcher: fetch the actions from the panel beneath, call the method.
Adding a command is now one interface method, an implementation in the two
or three controllers, and a keybinding — no plumbing.

The toggle-patch channel did double duty as the "this panel builds a custom
patch" signal for the inclusion gutter (shown only beneath a patch-building
panel, not the staging files panel). Collapsing the channels removes that
proxy, so make the classification explicit on DiffMainViewContext, whose
marker method now returns a DiffMainViewType (none / staging / patch-building)
instead of being a bare marker. The gutter shows only beneath a panel whose
type is patch-building (commit files, local commits, sub-commits, stash).

Behavior-preserving.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00

208 lines
6.6 KiB
Go

package controllers
import (
"path/filepath"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
// This controller is for all contexts that contain commit files.
var _ types.IController = &SwitchToDiffFilesController{}
type CanSwitchToDiffFiles interface {
types.IListContext
CanRebase() bool
GetSelectedRef() models.Ref
GetSelectedRefRangeForDiffFiles() *types.RefRange
}
// Not using our ListControllerTrait because we have our own way of working with
// range selections that's different from ListControllerTrait's
type SwitchToDiffFilesController struct {
baseController
c *ControllerCommon
context CanSwitchToDiffFiles
}
func NewSwitchToDiffFilesController(
c *ControllerCommon,
context CanSwitchToDiffFiles,
) *SwitchToDiffFilesController {
return &SwitchToDiffFilesController{
baseController: baseController{},
c: c,
context: context,
}
}
func (self *SwitchToDiffFilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
bindings := []*types.Binding{
{
Keys: opts.GetKeys(opts.Config.Universal.GoInto),
Handler: self.enter,
GetDisabledReason: self.canEnter,
Description: self.c.Tr.ViewItemFiles,
},
}
return bindings
}
func (self *SwitchToDiffFilesController) GetFocusedMainViewActions() types.FocusedMainViewActions {
return self
}
func (self *SwitchToDiffFilesController) OnClick(mainViewName string, clickedLineIdx int) error {
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, clickedLineIdx)
if !ok {
return nil
}
line, isDeletion := info.PatchSelectLine()
// Capture before self.enter() pushes the commit files panel, which
// re-renders the main view. We escape "all the way out" to this side
// panel (skipping the commit files panel), then focus the main view.
snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context)
if err := self.enter(); err != nil {
return err
}
context := self.c.Contexts().CommitFiles
var node *filetree.CommitFileNode
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
if err != nil {
return err
}
relativePath = "./" + relativePath
context.CommitFileTreeViewModel.ExpandToPath(relativePath)
self.c.PostRefreshUpdate(context)
idx, ok := context.CommitFileTreeViewModel.GetIndexForPath(relativePath)
if !ok {
return nil
}
context.SetSelectedLineIdx(idx)
context.GetViewTrait().FocusPoint(
context.ModelIndexToViewIndex(idx), false)
node = context.GetSelected()
return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, ClickedViewRealLineIsDeletion: isDeletion, SelectLineInDefaultMode: true})
}
func (self *SwitchToDiffFilesController) Context() types.Context {
return self.context
}
func (self *SwitchToDiffFilesController) GetOnDoubleClick() func() error {
return func() error {
if self.canEnter() == nil {
return self.enter()
}
return nil
}
}
func (self *SwitchToDiffFilesController) enter() error {
ref := self.context.GetSelectedRef()
refsRange := self.context.GetSelectedRefRangeForDiffFiles()
commitFilesContext := self.c.Contexts().CommitFiles
canRebase := self.canRebase(ref, refsRange)
commitFilesContext.ClearFilter()
commitFilesContext.ReInit(ref, refsRange)
commitFilesContext.SetSelection(0)
commitFilesContext.SetCanRebase(canRebase)
commitFilesContext.SetParentContext(self.context)
commitFilesContext.SetWindowName(self.context.GetWindowName())
commitFilesContext.GetView().TitlePrefix = self.context.GetView().TitlePrefix
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.COMMIT_FILES},
Then: func() error {
if filterPath := self.c.Modes().Filtering.GetPath(); filterPath != "" {
path, err := filepath.Rel(self.c.Git().RepoPaths.RepoPath(), filterPath)
if err != nil {
path = filterPath
}
commitFilesContext.CommitFileTreeViewModel.SelectPath(
filepath.ToSlash(path), self.c.UserConfig().Gui.ShowRootItemInFileTree)
}
self.c.Context().Push(commitFilesContext, types.OnFocusOpts{})
return nil
},
})
return nil
}
// canRebase reports whether patches built from the selected ref may modify commits —
// true only for commits of the currently checked-out branch, and not while diffing a
// different ref or over a range. Shared by entering the commit files panel and toggling
// patch lines straight from the main view.
func (self *SwitchToDiffFilesController) canRebase(ref models.Ref, refsRange *types.RefRange) bool {
canRebase := self.context.CanRebase()
if canRebase {
if self.c.Modes().Diffing.Active() {
if self.c.Modes().Diffing.Ref != ref.RefName() {
canRebase = false
}
} else if refsRange != nil {
canRebase = false
}
}
return canRebase
}
// PrimaryAction toggles the selected line(s) of the whole-commit diff into or out of the
// custom patch when space is pressed in the focused main view of the commits / sub-commits
// / stash panels. The patch target is the panel's selected ref (or range), matching the
// diff the main view shows. Unlike the commit files panel there are no per-file patch
// indicators to update, so the toggle refreshes cheaply: it re-renders just this panel's
// main + secondary views (leaving the commit list untouched, which a list refresh would
// needlessly reload on every keystroke), re-running the same diff command (scroll
// preserved) and repainting the inclusion gutter.
func (self *SwitchToDiffFilesController) PrimaryAction(mainViewName string, firstLineIdx int, lastLineIdx int) error {
ref := self.context.GetSelectedRef()
if ref == nil {
return nil
}
refsRange := self.context.GetSelectedRefRangeForDiffFiles()
from, to := context.FromAndToForDiff(ref, refsRange)
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
canRebase := self.canRebase(ref, refsRange)
return togglePatchFromFocusedMainView(self.c, mainViewName, firstLineIdx, lastLineIdx,
from, to, reverse, canRebase,
func() {
self.c.OnUIThread(func() error {
self.c.PostRefreshUpdate(self.context)
return nil
})
})
}
func (self *SwitchToDiffFilesController) canEnter() *types.DisabledReason {
refRange := self.context.GetSelectedRefRangeForDiffFiles()
if refRange != nil {
return nil
}
ref := self.context.GetSelectedRef()
if ref == nil {
return &types.DisabledReason{Text: self.c.Tr.NoItemSelected}
}
if ref.RefName() == "" {
return &types.DisabledReason{Text: self.c.Tr.SelectedItemDoesNotHaveFiles}
}
return nil
}