From 88dae2f2c6aeab7fcd08a3a5ea9137ae36642d08 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 19 Jun 2026 15:49:39 +0200 Subject: [PATCH] Collapse the focused-main-view handler channels into one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pkg/gui/context/base_context.go | 71 ++----- pkg/gui/context/commit_files_context.go | 4 +- pkg/gui/context/local_commits_context.go | 4 +- pkg/gui/context/reflog_commits_context.go | 6 +- pkg/gui/context/stash_context.go | 4 +- pkg/gui/context/sub_commits_context.go | 4 +- pkg/gui/context/working_tree_context.go | 4 +- pkg/gui/controllers/attach.go | 4 +- pkg/gui/controllers/base_controller.go | 10 +- .../controllers/commits_files_controller.go | 106 +++++----- pkg/gui/controllers/files_controller.go | 198 +++++++++--------- pkg/gui/controllers/helpers/staging_helper.go | 3 +- pkg/gui/controllers/main_view_controller.go | 45 ++-- .../switch_to_diff_files_controller.go | 130 ++++++------ pkg/gui/types/context.go | 89 ++++---- 15 files changed, 339 insertions(+), 343 deletions(-) diff --git a/pkg/gui/context/base_context.go b/pkg/gui/context/base_context.go index 8c92b240f..afd29ba89 100644 --- a/pkg/gui/context/base_context.go +++ b/pkg/gui/context/base_context.go @@ -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()) { diff --git a/pkg/gui/context/commit_files_context.go b/pkg/gui/context/commit_files_context.go index fed245f41..9117e8200 100644 --- a/pkg/gui/context/commit_files_context.go +++ b/pkg/gui/context/commit_files_context.go @@ -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( diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index 28bbb3409..8bab33801 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -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} diff --git a/pkg/gui/context/reflog_commits_context.go b/pkg/gui/context/reflog_commits_context.go index f3544d01e..ad389affb 100644 --- a/pkg/gui/context/reflog_commits_context.go +++ b/pkg/gui/context/reflog_commits_context.go @@ -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( diff --git a/pkg/gui/context/stash_context.go b/pkg/gui/context/stash_context.go index 50bddf5a2..99919868a 100644 --- a/pkg/gui/context/stash_context.go +++ b/pkg/gui/context/stash_context.go @@ -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, diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go index a41daa6a5..14a6e49ca 100644 --- a/pkg/gui/context/sub_commits_context.go +++ b/pkg/gui/context/sub_commits_context.go @@ -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, diff --git a/pkg/gui/context/working_tree_context.go b/pkg/gui/context/working_tree_context.go index 51cc169c1..706bed3b6 100644 --- a/pkg/gui/context/working_tree_context.go +++ b/pkg/gui/context/working_tree_context.go @@ -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( diff --git a/pkg/gui/controllers/attach.go b/pkg/gui/controllers/attach.go index b26c0cf2a..ac9edb6af 100644 --- a/pkg/gui/controllers/attach.go +++ b/pkg/gui/controllers/attach.go @@ -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()) diff --git a/pkg/gui/controllers/base_controller.go b/pkg/gui/controllers/base_controller.go index 2b9e8803e..7b7efc1c2 100644 --- a/pkg/gui/controllers/base_controller.go +++ b/pkg/gui/controllers/base_controller.go @@ -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 } diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 629c9de04..5154e9581 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -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 diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 305bbdd46..a451ebf05 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -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 diff --git a/pkg/gui/controllers/helpers/staging_helper.go b/pkg/gui/controllers/helpers/staging_helper.go index 0bd33b0f3..4bc00f69f 100644 --- a/pkg/gui/controllers/helpers/staging_helper.go +++ b/pkg/gui/controllers/helpers/staging_helper.go @@ -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 } diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 7bad13182..a60333e0f 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -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 } diff --git a/pkg/gui/controllers/switch_to_diff_files_controller.go b/pkg/gui/controllers/switch_to_diff_files_controller.go index 39733fb89..2fc533317 100644 --- a/pkg/gui/controllers/switch_to_diff_files_controller.go +++ b/pkg/gui/controllers/switch_to_diff_files_controller.go @@ -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 { diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 1ea6ff759..b7fbdb1d2 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -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 {