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>
This commit is contained in:
Stefan Haller 2026-06-19 15:49:39 +02:00
parent 96b09bb41d
commit 88dae2f2c6
15 changed files with 339 additions and 343 deletions

View file

@ -13,17 +13,15 @@ type BaseContext struct {
windowName string
onGetOptionsMap func() map[string]string
keybindingsFns []types.KeybindingsFn
mouseKeybindingsFns []types.MouseKeybindingsFn
onDoubleClickFn func() error
onClickFn func(opts gocui.ViewMouseBindingOpts) error
onClickFocusedMainViewFn onClickFocusedMainViewFn
onStageFocusedMainViewFn onStageFocusedMainViewFn
onTogglePatchFocusedMainViewFn onTogglePatchFocusedMainViewFn
onRenderToMainFn func()
onFocusFns []onFocusFn
onFocusLostFns []onFocusLostFn
onQuitFns []func()
keybindingsFns []types.KeybindingsFn
mouseKeybindingsFns []types.MouseKeybindingsFn
onDoubleClickFn func() error
onClickFn func(opts gocui.ViewMouseBindingOpts) error
focusedMainViewActions types.FocusedMainViewActions
onRenderToMainFn func()
onFocusFns []onFocusFn
onFocusLostFns []onFocusLostFn
onQuitFns []func()
focusable bool
transient bool
@ -36,11 +34,8 @@ type BaseContext struct {
}
type (
onFocusFn = func(types.OnFocusOpts)
onFocusLostFn = func(types.OnFocusLostOpts)
onClickFocusedMainViewFn = func(mainViewName string, clickedLineIdx int) error
onStageFocusedMainViewFn = func(mainViewName string, firstLineIdx int, lastLineIdx int) error
onTogglePatchFocusedMainViewFn = func(mainViewName string, firstLineIdx int, lastLineIdx int) error
onFocusFn = func(types.OnFocusOpts)
onFocusLostFn = func(types.OnFocusLostOpts)
)
var _ types.IBaseContext = &BaseContext{}
@ -149,9 +144,7 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() {
self.onQuitFns = nil
self.onDoubleClickFn = nil
self.onClickFn = nil
self.onClickFocusedMainViewFn = nil
self.onStageFocusedMainViewFn = nil
self.onTogglePatchFocusedMainViewFn = nil
self.focusedMainViewActions = nil
self.onRenderToMainFn = nil
}
@ -173,12 +166,12 @@ func (self *BaseContext) AddOnClickFn(fn func(opts gocui.ViewMouseBindingOpts) e
}
}
func (self *BaseContext) AddOnClickFocusedMainViewFn(fn onClickFocusedMainViewFn) {
if fn != nil {
if self.onClickFocusedMainViewFn != nil {
panic("only one controller is allowed to set an onClickFocusedMainViewFn")
func (self *BaseContext) AddFocusedMainViewActions(actions types.FocusedMainViewActions) {
if actions != nil {
if self.focusedMainViewActions != nil {
panic("only one controller is allowed to set the focused main view actions")
}
self.onClickFocusedMainViewFn = fn
self.focusedMainViewActions = actions
}
}
@ -190,34 +183,8 @@ func (self *BaseContext) GetOnClick() func(opts gocui.ViewMouseBindingOpts) erro
return self.onClickFn
}
func (self *BaseContext) GetOnClickFocusedMainView() onClickFocusedMainViewFn {
return self.onClickFocusedMainViewFn
}
func (self *BaseContext) AddOnStageFocusedMainViewFn(fn onStageFocusedMainViewFn) {
if fn != nil {
if self.onStageFocusedMainViewFn != nil {
panic("only one controller is allowed to set an onStageFocusedMainViewFn")
}
self.onStageFocusedMainViewFn = fn
}
}
func (self *BaseContext) GetOnStageFocusedMainView() onStageFocusedMainViewFn {
return self.onStageFocusedMainViewFn
}
func (self *BaseContext) AddOnTogglePatchFocusedMainViewFn(fn onTogglePatchFocusedMainViewFn) {
if fn != nil {
if self.onTogglePatchFocusedMainViewFn != nil {
panic("only one controller is allowed to set an onTogglePatchFocusedMainViewFn")
}
self.onTogglePatchFocusedMainViewFn = fn
}
}
func (self *BaseContext) GetOnTogglePatchFocusedMainView() onTogglePatchFocusedMainViewFn {
return self.onTogglePatchFocusedMainViewFn
func (self *BaseContext) GetFocusedMainViewActions() types.FocusedMainViewActions {
return self.focusedMainViewActions
}
func (self *BaseContext) AddOnRenderToMainFn(fn func()) {

View file

@ -25,7 +25,9 @@ var (
_ types.DiffMainViewContext = (*CommitFilesContext)(nil)
)
func (self *CommitFilesContext) IsDiffMainViewContext() {}
func (self *CommitFilesContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
viewModel := filetree.NewCommitFileTreeViewModel(

View file

@ -37,7 +37,9 @@ var (
_ types.DiffMainViewContext = (*LocalCommitsContext)(nil)
)
func (self *LocalCommitsContext) IsDiffMainViewContext() {}
func (self *LocalCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
dropIndicator := &commitDropIndicator{insertionIndex: -1}

View file

@ -19,7 +19,11 @@ var (
_ types.DiffMainViewContext = (*ReflogCommitsContext)(nil)
)
func (self *ReflogCommitsContext) IsDiffMainViewContext() {}
// Reflog shows a commit's diff but has no primary action (building a patch from it is
// a deferred gap), so the focused main view has a selection but space does nothing.
func (self *ReflogCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypeNone
}
func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext {
viewModel := NewFilteredListViewModel(

View file

@ -17,7 +17,9 @@ var (
_ types.DiffMainViewContext = (*StashContext)(nil)
)
func (self *StashContext) IsDiffMainViewContext() {}
func (self *StashContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewStashContext(
c *ContextCommon,

View file

@ -27,7 +27,9 @@ var (
_ types.DiffMainViewContext = (*SubCommitsContext)(nil)
)
func (self *SubCommitsContext) IsDiffMainViewContext() {}
func (self *SubCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewSubCommitsContext(
c *ContextCommon,

View file

@ -20,7 +20,9 @@ var (
_ types.DiffMainViewContext = (*WorkingTreeContext)(nil)
)
func (self *WorkingTreeContext) IsDiffMainViewContext() {}
func (self *WorkingTreeContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypeStaging
}
func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
viewModel := filetree.NewFileTreeViewModel(

View file

@ -8,9 +8,7 @@ func AttachControllers(context types.Context, controllers ...types.IController)
context.AddMouseKeybindingsFn(controller.GetMouseKeybindings)
context.AddOnDoubleClickFn(controller.GetOnDoubleClick())
context.AddOnClickFn(controller.GetOnClick())
context.AddOnClickFocusedMainViewFn(controller.GetOnClickFocusedMainView())
context.AddOnStageFocusedMainViewFn(controller.GetOnStageFocusedMainView())
context.AddOnTogglePatchFocusedMainViewFn(controller.GetOnTogglePatchFocusedMainView())
context.AddFocusedMainViewActions(controller.GetFocusedMainViewActions())
context.AddOnRenderToMainFn(controller.GetOnRenderToMain())
context.AddOnFocusFn(controller.GetOnFocus())
context.AddOnFocusLostFn(controller.GetOnFocusLost())

View file

@ -19,15 +19,7 @@ func (self *baseController) GetOnDoubleClick() func() error {
return nil
}
func (self *baseController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
return nil
}
func (self *baseController) GetOnStageFocusedMainView() func(mainViewName string, firstLineIdx int, lastLineIdx int) error {
return nil
}
func (self *baseController) GetOnTogglePatchFocusedMainView() func(mainViewName string, firstLineIdx int, lastLineIdx int) error {
func (self *baseController) GetFocusedMainViewActions() types.FocusedMainViewActions {
return nil
}

View file

@ -552,62 +552,62 @@ func (self *CommitFilesController) expandAll() error {
return nil
}
func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
return func(mainViewName string, clickedLineIdx int) error {
// Capture before any mutation below that might re-render the main view.
snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context())
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, clickedLineIdx)
line := -1
isDeletion := false
if ok {
line, isDeletion = info.PatchSelectLine()
}
node := self.getSelectedItem()
if node == nil {
return nil
}
if !node.IsFile() && ok {
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
if err != nil {
return err
}
relativePath = "./" + relativePath
self.context().CommitFileTreeViewModel.ExpandToPath(relativePath)
self.c.PostRefreshUpdate(self.context())
idx, ok := self.context().CommitFileTreeViewModel.GetIndexForPath(relativePath)
if ok {
self.context().SetSelectedLineIdx(idx)
self.context().GetViewTrait().FocusPoint(
self.context().ModelIndexToViewIndex(idx), false)
node = self.context().GetSelected()
}
}
// Entered from the focused main view, so escaping returns there.
return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, ClickedViewRealLineIsDeletion: isDeletion, SelectLineInDefaultMode: true})
}
func (self *CommitFilesController) GetFocusedMainViewActions() types.FocusedMainViewActions {
return self
}
// GetOnTogglePatchFocusedMainView toggles the selected diff line(s) into or out of the
// custom patch when space is pressed in the focused main view of a commit's files. The
// per-file diff's patch target comes from the commit files context. It refreshes
// normally afterwards so the file's patch-status indicator in the browser updates along
// with the secondary patch view (the commits / sub-commits / stash panels, which build
// from the whole-commit diff, have no such indicator and refresh more cheaply).
func (self *CommitFilesController) GetOnTogglePatchFocusedMainView() func(mainViewName string, firstLineIdx int, lastLineIdx int) error {
return func(mainViewName string, firstLineIdx int, lastLineIdx int) error {
from, to, reverse := self.c.Helpers().CommitFiles.CurrentFromToReverseForPatchBuilding()
canRebase := self.context().GetCanRebase()
return togglePatchFromFocusedMainView(self.c, mainViewName, firstLineIdx, lastLineIdx,
from, to, reverse, canRebase,
func() {
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMIT_FILES}})
})
func (self *CommitFilesController) OnClick(mainViewName string, clickedLineIdx int) error {
// Capture before any mutation below that might re-render the main view.
snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context())
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, clickedLineIdx)
line := -1
isDeletion := false
if ok {
line, isDeletion = info.PatchSelectLine()
}
node := self.getSelectedItem()
if node == nil {
return nil
}
if !node.IsFile() && ok {
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
if err != nil {
return err
}
relativePath = "./" + relativePath
self.context().CommitFileTreeViewModel.ExpandToPath(relativePath)
self.c.PostRefreshUpdate(self.context())
idx, ok := self.context().CommitFileTreeViewModel.GetIndexForPath(relativePath)
if ok {
self.context().SetSelectedLineIdx(idx)
self.context().GetViewTrait().FocusPoint(
self.context().ModelIndexToViewIndex(idx), false)
node = self.context().GetSelected()
}
}
// Entered from the focused main view, so escaping returns there.
return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, ClickedViewRealLineIsDeletion: isDeletion, SelectLineInDefaultMode: true})
}
// PrimaryAction toggles the selected diff line(s) into or out of the custom patch when
// space is pressed in the focused main view of a commit's files. The per-file diff's
// patch target comes from the commit files context. It refreshes normally afterwards so
// the file's patch-status indicator in the browser updates along with the secondary patch
// view (the commits / sub-commits / stash panels, which build from the whole-commit diff,
// have no such indicator and refresh more cheaply).
func (self *CommitFilesController) PrimaryAction(mainViewName string, firstLineIdx int, lastLineIdx int) error {
from, to, reverse := self.c.Helpers().CommitFiles.CurrentFromToReverseForPatchBuilding()
canRebase := self.context().GetCanRebase()
return togglePatchFromFocusedMainView(self.c, mainViewName, firstLineIdx, lastLineIdx,
from, to, reverse, canRebase,
func() {
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMIT_FILES}})
})
}
// pathsForDiff returns the file paths to use for a diff command. When a text

View file

@ -407,122 +407,124 @@ func (self *FilesController) GetOnDoubleClick() func() error {
})
}
func (self *FilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
return func(mainViewName string, clickedLineIdx int) error {
// Capture before any mutation below that might re-render the main view.
snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context())
func (self *FilesController) GetFocusedMainViewActions() types.FocusedMainViewActions {
return self
}
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, clickedLineIdx)
line := -1
isDeletion := false
if ok {
line, isDeletion = info.PatchSelectLine()
}
func (self *FilesController) OnClick(mainViewName string, clickedLineIdx int) error {
// Capture before any mutation below that might re-render the main view.
snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context())
node := self.context().GetSelected()
if node == nil {
return nil
}
if !node.IsFile() && ok {
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
if err != nil {
return err
}
relativePath = "./" + relativePath
self.context().FileTreeViewModel.ExpandToPath(relativePath)
self.c.PostRefreshUpdate(self.context())
idx, ok := self.context().FileTreeViewModel.GetIndexForPath(relativePath)
if ok {
self.context().SetSelectedLineIdx(idx)
self.context().GetViewTrait().FocusPoint(
self.context().ModelIndexToViewIndex(idx), false)
}
}
return self.EnterFile(snapshot, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, ClickedViewRealLineIsDeletion: isDeletion, SelectLineInDefaultMode: true})
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, clickedLineIdx)
line := -1
isDeletion := false
if ok {
line, isDeletion = info.PatchSelectLine()
}
node := self.context().GetSelected()
if node == nil {
return nil
}
if !node.IsFile() && ok {
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
if err != nil {
return err
}
relativePath = "./" + relativePath
self.context().FileTreeViewModel.ExpandToPath(relativePath)
self.c.PostRefreshUpdate(self.context())
idx, ok := self.context().FileTreeViewModel.GetIndexForPath(relativePath)
if ok {
self.context().SetSelectedLineIdx(idx)
self.context().GetViewTrait().FocusPoint(
self.context().ModelIndexToViewIndex(idx), false)
}
}
return self.EnterFile(snapshot, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, ClickedViewRealLineIsDeletion: isDeletion, SelectLineInDefaultMode: true})
}
// diffSplitState reports, for the given file node, how the focused main view lays
// out its diff: whether it's split into unstaged (Normal) and staged
// (NormalSecondary) halves, and — when not split — whether the single Normal view
// shows the staged diff (which happens when the file has only staged changes).
// GetOnRenderToMain and GetOnStageFocusedMainView share this so the staging
// direction can't drift from what's on screen.
// GetOnRenderToMain and PrimaryAction share this so the staging direction
// can't drift from what's on screen.
func (self *FilesController) diffSplitState(node *filetree.FileNode) (split bool, mainShowsStaged bool) {
split = self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
mainShowsStaged = !split && node.GetHasStagedChanges()
return split, mainShowsStaged
}
func (self *FilesController) GetOnStageFocusedMainView() func(mainViewName string, firstLineIdx int, lastLineIdx int) error {
return func(mainViewName string, firstLineIdx int, lastLineIdx int) error {
if self.c.UserConfig().Git.DiffContextSize == 0 {
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
}
// PrimaryAction stages (or unstages) the selected diff line(s) when space is pressed in
// the focused main view of the working-tree files panel.
func (self *FilesController) PrimaryAction(mainViewName string, firstLineIdx int, lastLineIdx int) error {
if self.c.UserConfig().Git.DiffContextSize == 0 {
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
}
node := self.context().GetSelected()
if node == nil {
return nil
}
infos := self.c.Helpers().Staging.ChangeLinesInViewRange(mainViewName, firstLineIdx, lastLineIdx)
if len(infos) == 0 {
return nil
}
// 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)
reverse := mainShowsStaged || mainViewName == self.c.Contexts().NormalSecondary.GetViewName()
// 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}})
// Focus follows the side that was acted on. Staging keeps it in the main half
// (which always holds the unstaged side, or the staged side once the file has
// only staged changes). Unstaging keeps it on the staged side, which lives in
// the secondary half once the file is split into staged + unstaged, and moves
// back to the main half when the staged side empties and the split collapses.
// The model is up to date now (Refresh above is synchronous), so the post-op
// split is read from the freshly selected node.
focusViewName := self.c.Contexts().Normal.GetViewName()
if reverse {
if node := self.context().GetSelected(); node != nil {
if split, _ := self.diffSplitState(node); split {
focusViewName = self.c.Contexts().NormalSecondary.GetViewName()
}
}
}
// The staging Refresh above queued the main-view re-render; re-establish the
// selection in whichever pane now holds the acted-on side once that render lands,
// and focus that pane if staging moved it there.
revealSelectionAfterPrimaryAction(self.c, mainViewName, focusViewName, firstLineIdx)
if focusViewName != mainViewName {
self.c.Context().Push(mainContextForViewName(self.c, focusViewName), types.OnFocusOpts{})
}
node := self.context().GetSelected()
if node == nil {
return nil
}
infos := self.c.Helpers().Staging.ChangeLinesInViewRange(mainViewName, firstLineIdx, lastLineIdx)
if len(infos) == 0 {
return nil
}
// 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)
reverse := mainShowsStaged || mainViewName == self.c.Contexts().NormalSecondary.GetViewName()
// 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}})
// Focus follows the side that was acted on. Staging keeps it in the main half
// (which always holds the unstaged side, or the staged side once the file has
// only staged changes). Unstaging keeps it on the staged side, which lives in
// the secondary half once the file is split into staged + unstaged, and moves
// back to the main half when the staged side empties and the split collapses.
// The model is up to date now (Refresh above is synchronous), so the post-op
// split is read from the freshly selected node.
focusViewName := self.c.Contexts().Normal.GetViewName()
if reverse {
if node := self.context().GetSelected(); node != nil {
if split, _ := self.diffSplitState(node); split {
focusViewName = self.c.Contexts().NormalSecondary.GetViewName()
}
}
}
// The staging Refresh above queued the main-view re-render; re-establish the
// selection in whichever pane now holds the acted-on side once that render lands,
// and focus that pane if staging moved it there.
revealSelectionAfterPrimaryAction(self.c, mainViewName, focusViewName, firstLineIdx)
if focusViewName != mainViewName {
self.c.Context().Push(mainContextForViewName(self.c, focusViewName), types.OnFocusOpts{})
}
return nil
}
// fileForDiffLinePath maps a diff line's absolute file path (as carried by the

View file

@ -732,7 +732,8 @@ func (self *StagingHelper) RefreshInclusionGutter() {
}
sidePanel := self.c.Context().NextInStack(mainContext)
if sidePanel == nil || sidePanel.GetOnTogglePatchFocusedMainView() == nil {
diffMainView, ok := sidePanel.(types.DiffMainViewContext)
if !ok || diffMainView.GetDiffMainViewType() != types.DiffMainViewTypePatchBuilding {
v.SetInclusionGutter(false, nil)
return
}

View file

@ -74,7 +74,7 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
},
{
Keys: opts.GetKeys(opts.Config.Universal.Select),
Handler: self.stageSelectedLine,
Handler: self.primaryAction,
Description: self.c.Tr.Stage,
Tooltip: self.c.Tr.StageSelectionTooltip,
DisplayOnScreen: selectionShown,
@ -279,27 +279,31 @@ func (self *MainViewController) isDiffView() bool {
return sidePanelShowsDiff(self.c.Context().NextInStack(self.context))
}
// stageSelectedLine acts on the selected diff line(s) — a single line, a range, or a
// hunk — delegating the primary action to the side panel beneath the focused main view,
// since what the action means is the panel's business: the working tree stages, while
// commits toggle the selection into a custom patch. Each handler does its own re-render
// and re-establishes the selection afterwards (see revealSelectionAfterPrimaryAction), so
// the dispatcher just hands over the selected range. Panels whose diff supports neither
// register no handler, so this is a no-op there.
func (self *MainViewController) stageSelectedLine() error {
// focusedMainViewActions returns the actions the side panel beneath the focused main
// view offers on its diff (diving in, staging, patch toggling), or nil when there is no
// panel beneath or its diff offers none.
func (self *MainViewController) focusedMainViewActions() types.FocusedMainViewActions {
sidePanelContext := self.c.Context().NextInStack(self.context)
if sidePanelContext == nil {
return nil
}
return sidePanelContext.GetFocusedMainViewActions()
}
// primaryAction acts on the selected diff line(s) — a single line, a range, or a hunk —
// delegating to the side panel beneath the focused main view, since what the action means
// is the panel's business: the working tree stages, while commits toggle the selection
// into a custom patch. The handler does its own re-render and re-establishes the selection
// afterwards (see revealSelectionAfterPrimaryAction), so the dispatcher just hands over
// the selected range. A no-op when the panel beneath offers no actions.
func (self *MainViewController) primaryAction() error {
actions := self.focusedMainViewActions()
if actions == nil {
return nil
}
v := self.context.GetView()
first, last := v.SelectedLineRange()
if handler := sidePanelContext.GetOnStageFocusedMainView(); handler != nil {
return handler(self.context.GetViewName(), first, last)
}
if handler := sidePanelContext.GetOnTogglePatchFocusedMainView(); handler != nil {
return handler(self.context.GetViewName(), first, last)
}
return nil
return actions.PrimaryAction(self.context.GetViewName(), first, last)
}
// revealSelectionAfterPrimaryAction re-establishes the focused-main-view selection after a
@ -343,12 +347,11 @@ func (self *MainViewController) enter() error {
}
// enterForLine dives into staging/patch-building for the given line, by
// delegating to the side panel beneath the focused main view (the same handler
// used when clicking).
// delegating to the side panel beneath the focused main view (the same action
// taken when clicking).
func (self *MainViewController) enterForLine(lineIdx int) error {
sidePanelContext := self.c.Context().NextInStack(self.context)
if sidePanelContext != nil && sidePanelContext.GetOnClickFocusedMainView() != nil {
return sidePanelContext.GetOnClickFocusedMainView()(self.context.GetViewName(), lineIdx)
if actions := self.focusedMainViewActions(); actions != nil {
return actions.OnClick(self.context.GetViewName(), lineIdx)
}
return nil
}

View file

@ -53,45 +53,47 @@ func (self *SwitchToDiffFilesController) GetKeybindings(opts types.KeybindingsOp
return bindings
}
func (self *SwitchToDiffFilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
return func(mainViewName string, clickedLineIdx int) error {
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, clickedLineIdx)
if !ok {
return nil
}
line, isDeletion := info.PatchSelectLine()
func (self *SwitchToDiffFilesController) GetFocusedMainViewActions() types.FocusedMainViewActions {
return self
}
// 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) 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 {
@ -159,35 +161,33 @@ func (self *SwitchToDiffFilesController) canRebase(ref models.Ref, refsRange *ty
return canRebase
}
// GetOnTogglePatchFocusedMainView 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) GetOnTogglePatchFocusedMainView() func(mainViewName string, firstLineIdx int, lastLineIdx int) error {
return func(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
})
})
// 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 {

View file

@ -95,18 +95,10 @@ type IBaseContext interface {
// that the generic ListController can be specialized by view-specific controllers.
// We'll need to think of a better way to do this.
AddOnDoubleClickFn(func() error)
// Likewise for the focused main view: we need this to communicate between a
// side panel controller and the focused main view controller.
AddOnClickFocusedMainViewFn(func(mainViewName string, clickedLineIdx int) error)
// And for staging the selected line(s) directly from the focused main view
// (space), delegated to the side panel that owns the diff being shown. The
// inclusive view-line range is the current selection (a single line, a range, or
// a hunk).
AddOnStageFocusedMainViewFn(func(mainViewName string, firstLineIdx int, lastLineIdx int) error)
// And for toggling the selected line(s) into/out of the custom patch from the
// focused main view (space), when the panel beneath builds a patch rather than
// staging.
AddOnTogglePatchFocusedMainViewFn(func(mainViewName string, firstLineIdx int, lastLineIdx int) error)
// Likewise for the focused main view: this is how a side panel controller exposes
// the actions its diff supports there (diving in, staging, patch toggling, …) to
// the focused main view controller. nil for panels with no such actions.
AddFocusedMainViewActions(FocusedMainViewActions)
// Adding on to the above, this is so that a list-specific handler can register
// a hook for doing additional click handling
AddOnClickFn(func(opts gocui.ViewMouseBindingOpts) error)
@ -187,16 +179,38 @@ type DiffableContext interface {
// main view shows a unified diff — files, local commits, sub-commits, reflog,
// stash, and commit files — as opposed to a commit log or other non-diff content
// (branches, tags, status, …). It is distinct from DiffableContext, which is
// about producing a diff between two refs for the diff menu. This is the signal
// for whether to show a selection in the focused main view: a selection is only
// meaningful where there are diff lines to act on (stage, edit, jump by hunk,
// open in a pull request).
// about producing a diff between two refs for the diff menu. Implementing it is
// the signal for whether to show a selection in the focused main view: a selection
// is only meaningful where there are diff lines to act on (stage, edit, jump by
// hunk, open in a pull request). The returned type additionally classifies what the
// primary action (space) does there.
type DiffMainViewContext interface {
Context
IsDiffMainViewContext()
GetDiffMainViewType() DiffMainViewType
}
// DiffMainViewType classifies what the primary action (space) does in a side panel's
// focused main view — and, for DiffMainViewTypePatchBuilding, that the focused main
// view shows the inclusion gutter (it marks which change lines are in the patch, so
// it's meaningful only beneath a patch-building panel, and even then only while a
// patch is active).
type DiffMainViewType int
const (
// DiffMainViewTypeNone: a diff is shown — so the focused main view still has a
// selection to edit, jump by hunk, or open in a pull request — but the primary
// action does nothing. Currently the reflog (building a patch from it is a deferred
// gap).
DiffMainViewTypeNone DiffMainViewType = iota
// DiffMainViewTypeStaging: the primary action stages/unstages into the working tree
// (the files panel).
DiffMainViewTypeStaging
// DiffMainViewTypePatchBuilding: the primary action toggles the selection into a
// custom patch (the commit files / commits / sub-commits / stash panels).
DiffMainViewTypePatchBuilding
)
type IListContext interface {
Context
@ -334,26 +348,31 @@ type HasKeybindings interface {
// decides not to do anything with the click.
GetOnClick() func(opts gocui.ViewMouseBindingOpts) error
// Implement this in a side-panel controller to get called when there's a click in the main view
// that belongs to your panel while the main view is already focused.
GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error
// Implement this in a side-panel controller to expose the actions its diff
// supports when shown in the focused main view (see FocusedMainViewActions).
// Return nil for a panel whose diff offers none.
GetFocusedMainViewActions() FocusedMainViewActions
}
// Implement this in a side-panel controller to stage/unstage the selected diff
// line(s) when the user presses space in the focused main view. The inclusive
// view-line range is the current selection (a single line, a range, or a hunk).
// The handler re-renders the diff and re-establishes the selection itself
// (staging/unstaging can move the acted-on side to the other pane, which the
// handler then focuses). Return a nil func to do nothing.
GetOnStageFocusedMainView() func(mainViewName string, firstLineIdx int, lastLineIdx int) error
// FocusedMainViewActions is the set of actions a side panel offers on its diff while
// that diff is shown in the focused main view. The focused main view controller owns
// the keybindings and the selection mechanics and dispatches to whichever panel is
// beneath it; what each action means is the panel's business — the working-tree files
// panel stages, the commit panels toggle the selection into a custom patch. The
// inclusive view-line range passed to the action methods is the current selection (a
// single line, a range, or a hunk). mainViewName identifies which of the two main
// panes the user acted in.
type FocusedMainViewActions interface {
// OnClick dives into staging / patch-building for the clicked line, the same way a
// click in the panel's own view does. Also used by enter (on the selected line).
OnClick(mainViewName string, clickedLineIdx int) error
// Implement this in a side-panel controller to toggle the selected diff line(s)
// into or out of the custom patch when the user presses space in the focused main
// view. It is the patch-building counterpart of GetOnStageFocusedMainView: the
// commit's diff is unchanged by the toggle (only the inclusion set changes), so
// unlike staging it does its work synchronously and the focused main view does
// nothing further. The inclusive view-line range is the current selection. Return
// a nil func to do nothing.
GetOnTogglePatchFocusedMainView() func(mainViewName string, firstLineIdx int, lastLineIdx int) error
// PrimaryAction acts on the selected diff line(s) when the user presses space:
// stage/unstage for the working-tree files panel, toggle into/out of the custom
// patch for the commit panels. The handler re-renders the diff and re-establishes
// the selection itself (staging can move the acted-on side to the other pane,
// which the handler then focuses).
PrimaryAction(mainViewName string, firstLineIdx int, lastLineIdx int) error
}
type IController interface {