Files
lazygit/pkg/gui/context.go
T
Stefan HallerandClaude Opus 5 0b16edb822 Show a selection in the focused main view
The focused main view could be scrolled but not pointed at: there was no
way to say "this line" or "these lines", which is what every line-level
interaction needs — editing a line, copying part of a diff, and, later,
staging. So a diff main view now always carries a selection while focused,
starting at the first change already on screen so that focusing doesn't
move the view.

The mode of the selection lives on the main context (a single line, a
range from a fixed anchor, or the change block around the cursor); the
selected line and the range anchor stay in the view itself, whose native
range select draws them, so there is no new highlight machinery. The modes
and their keys are the ones the staging view has, including that the arrow
keys step from block to block in hunk mode.

A pane holds something to select only when what it is showing is a diff
with changes in it, which the render works out once its content is final: a
placeholder message is not a diff, and neither is a diff with nothing in it
at all, such as a binary file's or an empty commit's. Whether a selection
is then drawn there follows from the context stack, as it does for every
other view.

A click in the focused main view now has a meaning again: it selects the
line it points at.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:01:01 +02:00

413 lines
11 KiB
Go

package gui
import (
"sync"
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
// This file is for the management of contexts. There is a context stack such that
// for example you might start off in the commits context and then open a menu, putting
// you in the menu context. When contexts are activated/deactivated certain things need
// to happen like showing/hiding views and rendering content.
type ContextMgr struct {
ContextStack []types.Context
sync.RWMutex
gui *Gui
allContexts *context.ContextTree
}
func NewContextMgr(
gui *Gui,
allContexts *context.ContextTree,
) *ContextMgr {
return &ContextMgr{
ContextStack: []types.Context{},
RWMutex: sync.RWMutex{},
gui: gui,
allContexts: allContexts,
}
}
// use when you don't want to return to the original context upon
// hitting escape: you want to go that context's parent instead.
func (self *ContextMgr) Replace(c types.Context) {
if !c.IsFocusable() {
return
}
self.Lock()
if len(self.ContextStack) == 0 {
self.ContextStack = []types.Context{c}
} else {
// replace the last item with the given item
self.ContextStack = append(self.ContextStack[0:len(self.ContextStack)-1], c)
}
self.Unlock()
self.Activate(c, types.OnFocusOpts{})
}
func (self *ContextMgr) Push(c types.Context, opts types.OnFocusOpts) {
if !c.IsFocusable() {
return
}
contextsToDeactivate, contextToActivate := self.pushToContextStack(c)
for _, contextToDeactivate := range contextsToDeactivate {
self.deactivate(contextToDeactivate, types.OnFocusLostOpts{NewContextKey: c.GetKey()})
}
if contextToActivate != nil {
self.Activate(contextToActivate, opts)
}
}
// Adjusts the context stack based on the context that's being pushed and
// returns (contexts to deactivate, context to activate)
func (self *ContextMgr) pushToContextStack(c types.Context) ([]types.Context, types.Context) {
contextsToDeactivate := []types.Context{}
self.Lock()
defer self.Unlock()
if len(self.ContextStack) > 0 &&
c.GetKey() == self.ContextStack[len(self.ContextStack)-1].GetKey() {
// Context being pushed is already on top of the stack: nothing to
// deactivate or activate
return contextsToDeactivate, nil
}
if len(self.ContextStack) == 0 {
self.ContextStack = append(self.ContextStack, c)
} else if c.GetKind() == types.SIDE_CONTEXT {
// if we are switching to a side context, remove all other contexts in the stack
contextsToDeactivate = lo.Filter(self.ContextStack, func(context types.Context, _ int) bool {
return context.GetKey() != c.GetKey()
})
self.ContextStack = []types.Context{c}
} else if c.GetKind() == types.MAIN_CONTEXT {
// if we're switching to a main context, remove all other main contexts in the stack
contextsToKeep := []types.Context{}
for _, stackContext := range self.ContextStack {
if stackContext.GetKind() == types.MAIN_CONTEXT {
contextsToDeactivate = append(contextsToDeactivate, stackContext)
} else {
contextsToKeep = append(contextsToKeep, stackContext)
}
}
self.ContextStack = append(contextsToKeep, c)
} else {
topContext := self.currentContextWithoutLock()
// if we're pushing the same context on, we do nothing.
if topContext.GetKey() != c.GetKey() {
// if top one is a temporary popup, we remove it. Ideally you'd be able to
// escape back to previous temporary popups, but because we're currently reusing
// views for this, you might not be able to get back to where you previously were.
// The exception is when going to the search context e.g. for searching a menu.
if (topContext.GetKind() == types.TEMPORARY_POPUP && c.GetKey() != context.SEARCH_CONTEXT_KEY) ||
// we only ever want one main context on the stack at a time.
(topContext.GetKind() == types.MAIN_CONTEXT && c.GetKind() == types.MAIN_CONTEXT) {
contextsToDeactivate = append(contextsToDeactivate, topContext)
_, self.ContextStack = utils.Pop(self.ContextStack)
}
self.ContextStack = append(self.ContextStack, c)
}
}
return contextsToDeactivate, c
}
func (self *ContextMgr) Pop() {
self.Lock()
if len(self.ContextStack) == 1 {
// cannot escape from bottommost context
self.Unlock()
return
}
var currentContext types.Context
currentContext, self.ContextStack = utils.Pop(self.ContextStack)
newContext := self.ContextStack[len(self.ContextStack)-1]
self.Unlock()
self.deactivate(currentContext, types.OnFocusLostOpts{NewContextKey: newContext.GetKey()})
self.Activate(newContext, types.OnFocusOpts{})
}
func (self *ContextMgr) deactivate(c types.Context, opts types.OnFocusLostOpts) {
view, _ := self.gui.c.GocuiGui().View(c.GetViewName())
if opts.NewContextKey != context.SEARCH_CONTEXT_KEY {
if c.GetKind() == types.MAIN_CONTEXT || c.GetKind() == types.TEMPORARY_POPUP {
self.gui.helpers.Search.CancelSearchIfSearching(c)
}
}
// if we are the kind of context that is sent to back upon deactivation, we should do that
if view != nil &&
(c.GetKind() == types.TEMPORARY_POPUP ||
c.GetKind() == types.PERSISTENT_POPUP) {
view.Visible = false
}
c.HandleFocusLost(opts)
}
func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
viewName := c.GetViewName()
v, err := self.gui.c.GocuiGui().View(viewName)
if err != nil {
panic(err)
}
self.gui.helpers.Window.SetWindowContext(c)
self.gui.helpers.Window.MoveToTopOfWindow(c)
inputViewName := c.GetInputViewName()
if _, err := self.gui.c.GocuiGui().SetCurrentView(inputViewName); err != nil {
panic(err)
}
self.gui.helpers.Search.RenderSearchStatus(c)
desiredTitle := c.Title()
if desiredTitle != "" {
v.Title = desiredTitle
}
v.Visible = true
self.gui.c.GocuiGui().Cursor = v.Editable && v.Mask == ""
self.UpdateSelectionHighlights()
c.HandleFocus(opts)
}
// UpdateSelectionHighlights re-derives which views draw a selection, and which of
// them draw theirs as the active one: a view shows a selection while its context is
// on the stack and has something to select, and the context the user is in shows the
// active selection while the ones behind it show inactive ones.
//
// Both of those can change, so this is called wherever they do: from Activate, which
// every change to the stack goes through; after a refresh, since that is when the
// contents of a list change; and from whoever tells a context that its content has
// gained or lost something to select.
func (self *ContextMgr) UpdateSelectionHighlights() {
self.RLock()
defer self.RUnlock()
onStack := set.NewFromSlice(lo.Map(self.ContextStack,
func(c types.Context, _ int) types.ContextKey { return c.GetKey() }))
currentKey := self.currentContextWithoutLock().GetKey()
for _, c := range self.allContexts.Flatten() {
// The global context has no view of its own.
if view := c.GetView(); view != nil {
view.Highlight = onStack.Includes(c.GetKey()) && c.HasSelectableContent()
view.HighlightInactive = c.GetKey() != currentKey
}
}
}
func (self *ContextMgr) Current() types.Context {
self.RLock()
defer self.RUnlock()
return self.currentContextWithoutLock()
}
func (self *ContextMgr) currentContextWithoutLock() types.Context {
if len(self.ContextStack) == 0 {
return self.gui.defaultSideContext()
}
return self.ContextStack[len(self.ContextStack)-1]
}
// Note that this could return the 'status' context which is not itself a list context.
func (self *ContextMgr) CurrentSide() types.Context {
self.RLock()
defer self.RUnlock()
stack := self.ContextStack
// find the first context in the stack with the type of types.SIDE_CONTEXT
for i := range stack {
context := stack[len(stack)-1-i]
if context.GetKind() == types.SIDE_CONTEXT {
return context
}
}
return self.gui.defaultSideContext()
}
// static as opposed to popup
func (self *ContextMgr) CurrentStatic() types.Context {
self.RLock()
defer self.RUnlock()
return self.currentStaticContextWithoutLock()
}
func (self *ContextMgr) currentStaticContextWithoutLock() types.Context {
stack := self.ContextStack
if len(stack) == 0 {
return self.gui.defaultSideContext()
}
// find the first context in the stack without a popup type
for i := range stack {
context := stack[len(stack)-1-i]
if context.GetKind() != types.TEMPORARY_POPUP && context.GetKind() != types.PERSISTENT_POPUP {
return context
}
}
return self.gui.defaultSideContext()
}
func (self *ContextMgr) ForEach(f func(types.Context)) {
self.RLock()
defer self.RUnlock()
for _, context := range self.gui.State.ContextMgr.ContextStack {
f(context)
}
}
func (self *ContextMgr) IsCurrent(c types.Context) bool {
return self.Current().GetKey() == c.GetKey()
}
func (self *ContextMgr) IsCurrentOrParent(c types.Context) bool {
current := self.Current()
for current != nil {
if current.GetKey() == c.GetKey() {
return true
}
current = current.GetParentContext()
}
return false
}
func (self *ContextMgr) AllFilterable() []types.IFilterableContext {
var result []types.IFilterableContext
for _, context := range self.allContexts.Flatten() {
if ctx, ok := context.(types.IFilterableContext); ok {
result = append(result, ctx)
}
}
return result
}
func (self *ContextMgr) AllSearchable() []types.ISearchableContext {
var result []types.ISearchableContext
for _, context := range self.allContexts.Flatten() {
if ctx, ok := context.(types.ISearchableContext); ok {
result = append(result, ctx)
}
}
return result
}
// all list contexts
func (self *ContextMgr) AllList() []types.IListContext {
var listContexts []types.IListContext
for _, context := range self.allContexts.Flatten() {
if listContext, ok := context.(types.IListContext); ok {
listContexts = append(listContexts, listContext)
}
}
return listContexts
}
func (self *ContextMgr) AllPatchExplorer() []types.IPatchExplorerContext {
var listContexts []types.IPatchExplorerContext
for _, context := range self.allContexts.Flatten() {
if listContext, ok := context.(types.IPatchExplorerContext); ok {
listContexts = append(listContexts, listContext)
}
}
return listContexts
}
func (self *ContextMgr) ContextForKey(key types.ContextKey) types.Context {
self.RLock()
defer self.RUnlock()
for _, context := range self.allContexts.Flatten() {
if context.GetKey() == key {
return context
}
}
return nil
}
func (self *ContextMgr) CurrentPopup() []types.Context {
self.RLock()
defer self.RUnlock()
return lo.Filter(self.ContextStack, func(context types.Context, _ int) bool {
return context.GetKind() == types.TEMPORARY_POPUP || context.GetKind() == types.PERSISTENT_POPUP
})
}
func (self *ContextMgr) NextInStack(c types.Context) types.Context {
self.RLock()
defer self.RUnlock()
for i := range self.ContextStack {
if self.ContextStack[i].GetKey() == c.GetKey() {
if i == 0 {
return nil
}
return self.ContextStack[i-1]
}
}
panic("context not in stack")
}
// IsInStack reports whether the given context is on the stack at all, for callers
// that can't otherwise know and would make NextInStack panic.
func (self *ContextMgr) IsInStack(c types.Context) bool {
self.RLock()
defer self.RUnlock()
return lo.ContainsBy(self.ContextStack, func(other types.Context) bool {
return other.GetKey() == c.GetKey()
})
}