Files
lazygit/pkg/gui/gui.go
T
Stefan HallerandClaude Opus 5 06b421ad0c Remember how to get back to a repo we entered a submodule from
Entering a submodule clears GIT_DIR and GIT_WORK_TREE, as it must: they
say where the superproject is. But the stack we push the superproject
onto so that escape brings us back only held its path, and for a repo
opened with --git-dir/--work-tree the path leads nowhere — git can't
find a repo there. Escaping out of a submodule of a dotfile repo failed
with "not a git repository", or, if some unrelated repo happened to lie
above the work tree, quietly switched to that one instead.

Push the environment onto the stack along with the path, taken from the
repo paths rather than from the process env, so that it also covers a
repo we worked the location out for ourselves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00

1303 lines
40 KiB
Go

package gui
import (
goContext "context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"regexp"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/jesseduffield/lazycore/pkg/boxlayout"
appTypes "github.com/jesseduffield/lazygit/pkg/app/types"
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/git_config"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/modes/cherrypicking"
"github.com/jesseduffield/lazygit/pkg/gui/modes/diffing"
"github.com/jesseduffield/lazygit/pkg/gui/modes/filtering"
"github.com/jesseduffield/lazygit/pkg/gui/modes/marked_base_commit"
"github.com/jesseduffield/lazygit/pkg/gui/popup"
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/authors"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/graph"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
"github.com/jesseduffield/lazygit/pkg/gui/services/custom_commands"
"github.com/jesseduffield/lazygit/pkg/gui/status"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/integration/components"
integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
"github.com/jesseduffield/lazygit/pkg/tasks"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/updates"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"github.com/sasha-s/go-deadlock"
)
const StartupPopupVersion = 5
// OverlappingEdges determines if panel edges overlap
var OverlappingEdges = false
type Repo string
// Gui wraps the gocui Gui object which handles rendering and events
type Gui struct {
*common.Common
g *gocui.Gui
gitVersion *git_commands.GitVersion
git *commands.GitCommand
os *oscommands.OSCommand
// this is the state of the GUI for the current repo
State *GuiRepoState
diffRendererConfig *config.DiffRendererConfigManager
CustomCommandsClient *custom_commands.Client
// this is a mapping of repos to gui states, so that we can restore the original
// gui state when returning from a subrepo.
// In repos with multiple worktrees, we store a separate repo state per worktree.
RepoStateMap map[Repo]*GuiRepoState
Config config.AppConfigurer
Updater *updates.Updater
statusManager *status.StatusManager
waitForIntro sync.WaitGroup
viewBufferManagerMap map[string]*tasks.ViewBufferManager
// holds a mapping of view names to ptmx's. This is for rendering command outputs
// from within a pty. The point of keeping track of them is so that if we re-size
// the window, we can tell the pty it needs to resize accordingly.
viewPtmxMap map[string]oscommands.Pty
stopChan chan struct{}
// when lazygit is opened outside a git directory we want to open to the most
// recent repo with the recent repos popup showing
showRecentRepos bool
Mutexes types.Mutexes
// when you enter into a submodule we'll append the superproject's location to
// this array so that you can return to the superproject
RepoPathStack *utils.Stack[types.RepoLocation]
// this tells us whether our views have been initially set up
ViewsSetup bool
Views types.Views
// Log of the commands/actions logged in the Command Log panel.
GuiLog []string
// the extras window contains things like the command log
ShowExtrasWindow bool
PopupHandler types.IPopupHandler
// Bumped every time we switch to a different repository (in resetState).
// Used to drop refresh results that were computed for a repo we've since
// navigated away from. See RefreshHelper.onUIThreadUnlessRepoChanged.
repoGeneration atomic.Int32
// we use this to decide whether we'll return to the original directory that
// lazygit was opened in, or if we'll retain the one we're currently in.
RetainOriginalDir bool
// stores long-running operations associated with items (e.g. when a branch
// is being pushed). At the moment the rule is to use an item operation when
// we need to talk to the remote.
itemOperations map[string]types.ItemOperation
itemOperationsMutex deadlock.Mutex
PrevLayout PrevLayout
// this is the initial dir we are in upon opening lazygit. We hold onto this
// in case we want to restore it before quitting for users who have set up
// the feature for changing directory upon quit.
// The reason we don't just wait until quit time to handle changing directories
// is because some users want to keep track of the current lazygit directory in an outside
// process
InitialDir string
BackgroundRoutineMgr *BackgroundRoutineMgr
// for accessing the gui's state from outside this package
stateAccessor *StateAccessor
Updating bool
c *helpers.HelperCommon
helpers *helpers.Helpers
previousLanguageConfig string
integrationTest integrationTypes.IntegrationTest
afterLayoutFuncs chan func() error
}
type StateAccessor struct {
gui *Gui
}
var _ types.IStateAccessor = new(StateAccessor)
func (self *StateAccessor) GetRepoPathStack() *utils.Stack[types.RepoLocation] {
return self.gui.RepoPathStack
}
func (self *StateAccessor) GetUpdating() bool {
return self.gui.Updating
}
func (self *StateAccessor) SetUpdating(value bool) {
self.gui.Updating = value
}
func (self *StateAccessor) GetRepoState() types.IRepoStateAccessor {
return self.gui.State
}
func (self *StateAccessor) GetRepoGeneration() int {
return int(self.gui.repoGeneration.Load())
}
func (self *StateAccessor) GetDiffRendererConfigManager() *config.DiffRendererConfigManager {
return self.gui.diffRendererConfig
}
func (self *StateAccessor) GetShowExtrasWindow() bool {
return self.gui.ShowExtrasWindow
}
func (self *StateAccessor) SetShowExtrasWindow(value bool) {
self.gui.ShowExtrasWindow = value
}
func (self *StateAccessor) GetRetainOriginalDir() bool {
return self.gui.RetainOriginalDir
}
func (self *StateAccessor) SetRetainOriginalDir(value bool) {
self.gui.RetainOriginalDir = value
}
func (self *StateAccessor) GetItemOperation(item types.HasUrn) types.ItemOperation {
self.gui.itemOperationsMutex.Lock()
defer self.gui.itemOperationsMutex.Unlock()
return self.gui.itemOperations[item.URN()]
}
func (self *StateAccessor) SetItemOperation(item types.HasUrn, operation types.ItemOperation) {
self.gui.itemOperationsMutex.Lock()
defer self.gui.itemOperationsMutex.Unlock()
self.gui.itemOperations[item.URN()] = operation
}
func (self *StateAccessor) ClearItemOperation(item types.HasUrn) {
self.gui.itemOperationsMutex.Lock()
defer self.gui.itemOperationsMutex.Unlock()
delete(self.gui.itemOperations, item.URN())
}
// we keep track of some stuff from one render to the next to see if certain
// things have changed
type PrevLayout struct {
Information string
MainWidth int
MainHeight int
}
type GuiRepoState struct {
Model *types.Model
Modes *types.Modes
SplitMainPanel bool
SearchState *types.SearchState
// Lets us not load everything at once. Written and read from refresh
// workers (the reflog/branches load transitions it INITIAL->COMPLETE), so
// it's atomic. Holds a types.StartupStage.
startupStage atomic.Int32
ContextMgr *ContextMgr
Contexts *context.ContextTree
// WindowViewNameMap is a mapping of windows to the current view of that window.
// Some views move between windows for example the commitFiles view and when cycling through
// side windows we need to know which view to give focus to for a given window
WindowViewNameMap *utils.ThreadSafeMap[string, string]
// tells us whether we've set up our views for the current repo. We'll need to
// do this whenever we switch back and forth between repos to get the views
// back in sync with the repo state
ViewsSetup bool
ScreenMode types.ScreenMode
CurrentPopupOpts *types.CreatePopupPanelOpts
LastBackgroundFetchTime time.Time
// Whether the rebase/merge/cherry-pick/revert that's currently in progress
// was started from within lazygit (as opposed to being started externally,
// e.g. in another terminal or by a coding agent). We only auto-prompt to
// continue such an operation once its conflicts are resolved if we started
// it ourselves; for an externally started one, popping up unbidden would be
// confusing. Reset whenever we observe that no operation is in progress.
//
// Written from both the files refresh worker and the merge/rebase result
// path (which runs on a worker for the async callers), and read from the
// files refresh worker, so it's atomic.
mergeOrRebaseStartedInLazygit atomic.Bool
}
var _ types.IRepoStateAccessor = new(GuiRepoState)
func (self *GuiRepoState) GetViewsSetup() bool {
return self.ViewsSetup
}
func (self *GuiRepoState) GetWindowViewNameMap() *utils.ThreadSafeMap[string, string] {
return self.WindowViewNameMap
}
func (self *GuiRepoState) GetStartupStage() types.StartupStage {
return types.StartupStage(self.startupStage.Load())
}
func (self *GuiRepoState) SetStartupStage(value types.StartupStage) {
self.startupStage.Store(int32(value))
}
func (self *GuiRepoState) GetCurrentPopupOpts() *types.CreatePopupPanelOpts {
return self.CurrentPopupOpts
}
func (self *GuiRepoState) SetCurrentPopupOpts(value *types.CreatePopupPanelOpts) {
self.CurrentPopupOpts = value
}
func (self *GuiRepoState) GetMergeOrRebaseStartedInLazygit() bool {
return self.mergeOrRebaseStartedInLazygit.Load()
}
func (self *GuiRepoState) SetMergeOrRebaseStartedInLazygit(value bool) {
self.mergeOrRebaseStartedInLazygit.Store(value)
}
func (self *GuiRepoState) GetScreenMode() types.ScreenMode {
return self.ScreenMode
}
func (self *GuiRepoState) SetScreenMode(value types.ScreenMode) {
self.ScreenMode = value
}
func (self *GuiRepoState) InSearchPrompt() bool {
return self.SearchState.SearchType() != types.SearchTypeNone
}
func (self *GuiRepoState) GetSearchState() *types.SearchState {
return self.SearchState
}
func (self *GuiRepoState) SetSplitMainPanel(value bool) {
self.SplitMainPanel = value
}
func (self *GuiRepoState) GetSplitMainPanel() bool {
return self.SplitMainPanel
}
func (gui *Gui) onSwitchToNewRepo(startArgs appTypes.StartArgs, contextKey types.ContextKey) error {
err := gui.onNewRepo(startArgs, contextKey)
if err == nil && gui.UserConfig().Git.AutoFetch && gui.UserConfig().Refresher.FetchInterval > 0 {
if time.Since(gui.State.LastBackgroundFetchTime) > gui.UserConfig().Refresher.FetchIntervalDuration() {
gui.BackgroundRoutineMgr.triggerImmediateFetch()
}
}
return err
}
func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.ContextKey) error {
// Don't assign to gui.git until we know we have one: this also runs when
// switching repos, and leaving the field nil would take down the repo we
// were in before, which is where the error puts us back.
git, err := commands.NewGitCommand(
gui.Common,
gui.gitVersion,
gui.os,
git_config.NewStdCachedGitConfig(gui.Log),
gui.diffRendererConfig,
)
if err != nil {
return err
}
gui.git = git
err = gui.Config.ReloadUserConfigForRepo(gui.getPerRepoConfigFiles())
if err != nil {
return err
}
err = gui.onUserConfigLoaded()
if err != nil {
return err
}
contextToPush := gui.resetState(startArgs)
gui.resetHelpersAndControllers()
if err := gui.resetKeybindings(); err != nil {
return err
}
gui.g.SetFocusHandler(func(Focused bool) error {
if Focused {
gui.git.Config.DropConfigCache()
oldConfig := gui.Config.GetUserConfig()
reloadErr, didChange := gui.Config.ReloadChangedUserConfigFiles()
if didChange && reloadErr == nil {
gui.c.Log.Info("User config changed - reloading")
reloadErr = gui.onUserConfigLoaded()
gui.reloadSidePanels()
if err := gui.resetKeybindings(); err != nil {
return err
}
if err := gui.checkForChangedConfigsThatDontAutoReload(oldConfig, gui.Config.GetUserConfig()); err != nil {
return err
}
}
gui.c.Log.Info("Receiving focus - refreshing")
gui.helpers.Refresh.Refresh(types.RefreshOptions{DontBlockRepoSwitch: true})
return reloadErr
}
return nil
})
gui.g.SetOpenHyperlinkFunc(func(url string, viewname string) error {
if strings.HasPrefix(url, "lazygit-edit:") {
re := regexp.MustCompile(`^lazygit-edit://(.+?)(?::(\d*))?$`)
matches := re.FindStringSubmatch(url)
if matches == nil {
return fmt.Errorf(gui.Tr.InvalidLazygitEditURL, url)
}
filepath := matches[1]
if matches[2] != "" {
lineNumber := utils.MustConvertToInt(matches[2])
lineNumber = gui.helpers.Diff.AdjustLineNumber(filepath, lineNumber, viewname)
return gui.helpers.Files.EditFileAtLine(filepath, lineNumber)
}
return gui.helpers.Files.EditFiles([]string{filepath})
}
if err := gui.os.OpenLink(url); err != nil {
return fmt.Errorf(gui.Tr.FailedToOpenURL, url, err)
}
return nil
})
gui.g.SetUpdateQueueHighWaterMarkHandler(func(depth int) {
gui.c.Log.Infof("User-event queue reached a new high-water mark: %d", depth)
})
gui.g.SetOnSelectSearchResultFunc(func(v *gocui.View, selectedLineIdx int) {
ctx, ok := gui.helpers.View.ContextForView(v.Name())
if ok {
if searchableContext, ok := ctx.(types.ISearchableContext); ok {
searchableContext.OnSearchSelect(selectedLineIdx)
}
}
})
gui.g.SetRenderSearchStatusFunc(func(v *gocui.View, index int, total int) {
ctx, ok := gui.helpers.View.ContextForView(v.Name())
if ok {
if searchableContext, ok := ctx.(types.ISearchableContext); ok {
searchableContext.RenderSearchStatus(index, total)
}
}
})
// if a context key has been given, push that instead, and set its index to 0
if contextKey != context.NO_CONTEXT {
contextToPush = gui.c.ContextForKey(contextKey)
// when we pass a list context, the expectation is that our cursor goes to the top,
// because e.g. with worktrees, we'll show the current worktree at the top of the list.
listContext, ok := contextToPush.(types.IListContext)
if ok {
listContext.GetList().SetSelection(0)
}
}
gui.c.Context().Push(contextToPush, types.OnFocusOpts{})
gui.render()
return nil
}
func (gui *Gui) getPerRepoConfigFiles() []*config.ConfigFile {
repoConfigFiles := []*config.ConfigFile{
// TODO: add filepath.Join(gui.git.RepoPaths.RepoPath(), ".lazygit.yml"),
// with trust prompt
{
Path: filepath.Join(gui.git.RepoPaths.RepoGitDirPath(), "lazygit.yml"),
Policy: config.ConfigFilePolicySkipIfMissing,
},
}
prevDir := gui.c.Git().RepoPaths.RepoPath()
dir := filepath.Dir(prevDir)
for dir != prevDir {
repoConfigFiles = utils.Prepend(repoConfigFiles, &config.ConfigFile{
Path: filepath.Join(dir, ".lazygit.yml"),
Policy: config.ConfigFilePolicySkipIfMissing,
})
prevDir = dir
dir = filepath.Dir(dir)
}
return repoConfigFiles
}
func (gui *Gui) onUserConfigLoaded() error {
userConfig := gui.Config.GetUserConfig()
gui.Common.SetUserConfig(userConfig)
if gui.previousLanguageConfig != userConfig.Gui.Language {
tr, err := i18n.NewTranslationSetFromConfig(gui.Log, userConfig.Gui.Language)
if err != nil {
return err
}
gui.c.Tr = tr
gui.previousLanguageConfig = userConfig.Gui.Language
}
gui.setColorScheme()
gui.configureViewProperties()
gui.g.SearchEscapeKeys = config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.Return)
gui.g.NextSearchMatchKeys = config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.NextMatch)
gui.g.PrevSearchMatchKeys = config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.PrevMatch)
gui.g.SetEditKeybindings(
config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.MoveWordLeft),
config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.MoveWordRight),
config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.BackspaceWord),
config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.ForwardDeleteWord),
)
gui.g.ShowListFooter = userConfig.Gui.ShowListFooter
gui.g.Mouse = userConfig.Gui.MouseEvents
// originally we could only hide the command log permanently via the config
// but now we do it via state. So we need to still support the config for the
// sake of backwards compatibility. We're making use of short circuiting here
gui.ShowExtrasWindow = userConfig.Gui.ShowCommandLog && !gui.c.GetAppState().HideCommandLog
authors.SetCustomAuthors(userConfig.Gui.AuthorColors)
if userConfig.Gui.NerdFontsVersion != "" {
icons.SetNerdFontsVersion(userConfig.Gui.NerdFontsVersion)
} else if userConfig.Gui.ShowIcons {
icons.SetNerdFontsVersion("2")
} else {
icons.SetNerdFontsVersion("")
}
if len(userConfig.Gui.BranchColorPatterns) > 0 {
presentation.SetCustomBranches(userConfig.Gui.BranchColorPatterns, true)
} else {
// Fall back to the deprecated branchColors config
presentation.SetCustomBranches(userConfig.Gui.BranchColors, false)
}
return nil
}
func (gui *Gui) checkForChangedConfigsThatDontAutoReload(oldConfig *config.UserConfig, newConfig *config.UserConfig) error {
configsThatDontAutoReload := []string{
"Git.AutoFetch",
"Git.AutoRefresh",
"Git.AutoDetectExternalChanges",
"Refresher.RefreshInterval",
"Refresher.FetchInterval",
"Refresher.ExternalChangeCheckInterval",
"Update.Method",
"Update.Days",
}
changedConfigs := []string{}
for _, config := range configsThatDontAutoReload {
old := reflect.ValueOf(oldConfig).Elem()
new := reflect.ValueOf(newConfig).Elem()
fieldNames := strings.Split(config, ".")
userFacingPath := make([]string, 0, len(fieldNames))
// navigate to the leaves in old and new config
for _, fieldName := range fieldNames {
f, _ := old.Type().FieldByName(fieldName)
userFacingName := f.Tag.Get("yaml")
if userFacingName == "" {
userFacingName = fieldName
}
userFacingPath = append(userFacingPath, userFacingName)
old = old.FieldByName(fieldName)
new = new.FieldByName(fieldName)
}
// if the value has changed, ...
if !old.Equal(new) {
// ... append it to the list of changed configs
changedConfigs = append(changedConfigs, strings.Join(userFacingPath, "."))
}
}
if len(changedConfigs) == 0 {
return nil
}
message := utils.ResolvePlaceholderString(
gui.c.Tr.NonReloadableConfigWarning,
map[string]string{
"configs": strings.Join(changedConfigs, "\n"),
},
)
gui.c.Confirm(types.ConfirmOpts{
Title: gui.c.Tr.NonReloadableConfigWarningTitle,
Prompt: message,
})
return nil
}
// resetState reuses the repo state from our repo state map, if the repo was
// open before; otherwise it creates a new one.
func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context {
// Bump the repo generation so that any refresh still in flight for the
// previous repo drops its model update instead of applying it here (see
// RefreshHelper.onUIThreadUnlessRepoChanged).
gui.repoGeneration.Add(1)
// Un-highlight the current view if there is one. The reason we do this is
// that the repo we are switching to might have a different view focused,
// and would then show an inactive highlight for the previous view.
if oldCurrentView := gui.g.CurrentView(); oldCurrentView != nil {
oldCurrentView.Highlight = false
}
worktreePath := gui.git.RepoPaths.WorktreePath()
if state := gui.RepoStateMap[Repo(worktreePath)]; state != nil {
gui.State = state
gui.State.ViewsSetup = false
// The repo we're switching to may have a per-repo config with a different
// side panel layout, so re-apply it to this repo's contexts.
gui.applySidePanelConfig()
// setting this to nil so we don't get stuck based on a popup that was
// previously opened
gui.State.CurrentPopupOpts = nil
return gui.c.Context().Current()
}
contextTree := gui.contextTree()
initialScreenMode := initialScreenMode(startArgs, gui.Config)
gui.State = &GuiRepoState{
ViewsSetup: false,
Model: &types.Model{
CommitFiles: nil,
Files: make([]*models.File, 0),
Commits: make([]*models.Commit, 0),
StashEntries: make([]*models.StashEntry, 0),
FilteredReflogCommits: make([]*models.Commit, 0),
ReflogCommits: make([]*models.Commit, 0),
BisectInfo: git_commands.NewNullBisectInfo(),
Authors: map[string]*models.Author{},
MainBranches: git_commands.NewMainBranches(gui.c.Common, gui.os.Cmd),
HashPool: &utils.StringPool{},
PullRequests: gui.loadCachedPullRequests(),
PullRequestsMap: make(map[string]*models.GithubPullRequest),
},
Modes: &types.Modes{
Filtering: filtering.New(startArgs.FilterPath, ""),
CherryPicking: cherrypicking.New(),
Diffing: diffing.New(),
MarkedBaseCommit: marked_base_commit.New(),
},
ScreenMode: initialScreenMode,
// TODO: only use contexts from context manager
ContextMgr: NewContextMgr(gui, contextTree),
Contexts: contextTree,
SearchState: types.NewSearchState(),
}
gui.RepoStateMap[Repo(worktreePath)] = gui.State
gui.applySidePanelConfig()
return initialContext(contextTree, startArgs)
}
func (gui *Gui) loadCachedPullRequests() []*models.GithubPullRequest {
repoPath := gui.git.RepoPaths.RepoPath()
cachedPRs, err := gui.Config.GetCachedGithubPullRequests(repoPath)
if err != nil {
gui.Log.Warnf("error loading GitHub pull request cache: %v", err)
}
return lo.Map(cachedPRs, func(cached config.CachedPullRequest, _ int) *models.GithubPullRequest {
return &models.GithubPullRequest{
HeadRefName: cached.HeadRefName,
Number: cached.Number,
Title: cached.Title,
State: cached.State,
ChecksState: cached.ChecksState,
Url: cached.Url,
HeadRepositoryOwner: models.GithubRepositoryOwner{
Login: cached.HeadRepositoryOwner,
},
}
})
}
func (gui *Gui) getViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager {
manager, ok := gui.viewBufferManagerMap[view.Name()]
if !ok {
return nil
}
return manager
}
// When scrolling a lazy-loaded view, we read enough lines to fill the viewport
// plus this many extra screenfuls, so that further scrolling has some runway
// and doesn't have to block on reading (and re-rendering) more lines on every
// wheel notch.
const scrollReadAheadScreenfuls = 3
// readLinesToFillView reads enough lines into the view's buffer to cover
// everything currently scrolled into view, plus a few screenfuls of read-ahead.
// Reading is idempotent (see ViewBufferManager.ReadLines), so if the buffer
// already extends far enough this does nothing.
func (gui *Gui) readLinesToFillView(view *gocui.View) {
if manager := gui.getViewBufferManagerForView(view); manager != nil {
viewportBottom := view.OriginY() + view.InnerHeight()
manager.ReadLines(viewportBottom + scrollReadAheadScreenfuls*view.InnerHeight())
}
}
func (gui *Gui) initialWindowViewNameMap(contextTree *context.ContextTree) *utils.ThreadSafeMap[string, string] {
result := utils.NewThreadSafeMap[string, string]()
for _, context := range contextTree.Flatten() {
result.Set(context.GetWindowName(), context.GetViewName())
}
// A side panel's window shows its first configured tab by default, which is
// not necessarily the context that won the loop above.
for _, panel := range gui.c.UserConfig().Gui.SidePanels {
result.Set(panel[0], sidePanelViewNames[panel[0]])
}
return result
}
func initialScreenMode(startArgs appTypes.StartArgs, config config.AppConfigurer) types.ScreenMode {
if startArgs.ScreenMode != "" {
return parseScreenModeArg(startArgs.ScreenMode)
} else if startArgs.FilterPath != "" || startArgs.GitArg != appTypes.GitArgNone {
return types.SCREEN_HALF
}
return parseScreenModeArg(config.GetUserConfig().Gui.ScreenMode)
}
func parseScreenModeArg(screenModeArg string) types.ScreenMode {
switch screenModeArg {
case "half":
return types.SCREEN_HALF
case "full":
return types.SCREEN_FULL
default:
return types.SCREEN_NORMAL
}
}
func initialContext(contextTree *context.ContextTree, startArgs appTypes.StartArgs) types.IListContext {
var initialContext types.IListContext = contextTree.Files
if startArgs.FilterPath != "" {
initialContext = contextTree.LocalCommits
} else if startArgs.GitArg != appTypes.GitArgNone {
switch startArgs.GitArg {
case appTypes.GitArgStatus:
initialContext = contextTree.Files
case appTypes.GitArgBranch:
initialContext = contextTree.Branches
case appTypes.GitArgLog:
initialContext = contextTree.LocalCommits
case appTypes.GitArgStash:
initialContext = contextTree.Stash
default:
panic("unhandled git arg")
}
}
return initialContext
}
func (gui *Gui) Contexts() *context.ContextTree {
return gui.State.Contexts
}
// for now the split view will always be on
// NewGui builds a new gui handler
func NewGui(
cmn *common.Common,
configurer config.AppConfigurer,
gitVersion *git_commands.GitVersion,
updater *updates.Updater,
showRecentRepos bool,
initialDir string,
test integrationTypes.IntegrationTest,
) (*Gui, error) {
gui := &Gui{
Common: cmn,
gitVersion: gitVersion,
Config: configurer,
Updater: updater,
statusManager: status.NewStatusManager(),
viewBufferManagerMap: map[string]*tasks.ViewBufferManager{},
viewPtmxMap: map[string]oscommands.Pty{},
showRecentRepos: showRecentRepos,
RepoPathStack: &utils.Stack[types.RepoLocation]{},
RepoStateMap: map[Repo]*GuiRepoState{},
GuiLog: []string{},
// initializing this to true for the time being; it will be reset to the
// real value after loading the user config:
ShowExtrasWindow: true,
InitialDir: initialDir,
afterLayoutFuncs: make(chan func() error, 1000),
itemOperations: make(map[string]types.ItemOperation),
}
gui.PopupHandler = popup.NewPopupHandler(
cmn,
// Raising a popup or menu pushes a context and mutates the popup views,
// and it can be triggered from a worker goroutine (e.g. a
// WithWaitingStatus handler that hits a merge conflict and asks the user
// how to proceed). Bounce the creation onto the UI thread so it can't
// race the layout/draw code. Doing it here, at the one point where these
// producers are injected, keeps every caller oblivious to the threading.
func(ctx goContext.Context, opts types.CreatePopupPanelOpts) {
gui.onUIThread(func() error {
gui.helpers.Confirmation.CreatePopupPanel(ctx, opts)
return nil
})
},
func() error { gui.c.Refresh(types.RefreshOptions{}); return nil },
func() { gui.State.ContextMgr.Pop() },
func() types.Context { return gui.State.ContextMgr.Current() },
func(opts types.CreateMenuOptions) error {
gui.onUIThread(func() error { return gui.createMenu(opts) })
return nil
},
func(message string, f func(gocui.Task) error) { gui.helpers.AppStatus.WithWaitingStatus(message, f) },
func(opts types.WaitingStatusOpts, f func(gocui.Task) error) {
gui.helpers.AppStatus.WithWaitingStatusBlockingInput(opts, f)
},
func(message string, kind types.ToastKind) { gui.helpers.AppStatus.Toast(message, kind) },
func() string { return gui.Views.Prompt.TextArea.GetContent() },
func() bool { return gui.c.InDemo() },
)
guiCommon := &guiCommon{gui: gui, IPopupHandler: gui.PopupHandler}
helperCommon := &helpers.HelperCommon{IGuiCommon: guiCommon, Common: cmn, IGetContexts: gui}
credentialsHelper := helpers.NewCredentialsHelper(helperCommon)
guiIO := oscommands.NewGuiIO(
cmn.Log,
gui.LogCommand,
gui.getCmdWriter,
credentialsHelper.PromptUserForCredential,
)
osCommand := oscommands.NewOSCommand(cmn, configurer, oscommands.GetPlatform(), guiIO)
gui.os = osCommand
// storing this stuff on the gui for now to ease refactoring
// TODO: reset these controllers upon changing repos due to state changing
gui.c = helperCommon
gui.BackgroundRoutineMgr = &BackgroundRoutineMgr{gui: gui}
gui.stateAccessor = &StateAccessor{gui: gui}
gui.diffRendererConfig = config.NewDiffRendererConfigManager(func() *config.UserConfig { return gui.UserConfig() })
return gui, nil
}
var RuneReplacements = map[rune]string{
// for the commit graph
graph.MergeSymbol: "M",
graph.CommitSymbol: "o",
}
func (gui *Gui) initGocui(headless bool, test integrationTypes.IntegrationTest) (*gocui.Gui, error) {
runInSandbox := os.Getenv(components.SANDBOX_ENV_VAR) == "true"
playRecording := test != nil && !runInSandbox
width, height := 0, 0
if test != nil {
if test.RequiresHeadless() {
if runInSandbox {
panic("Test requires headless, can't run in sandbox")
}
headless = true
}
width, height = test.HeadlessDimensions()
}
g, err := gocui.NewGui(gocui.NewGuiOpts{
OutputMode: gocui.OutputTrue,
SupportOverlaps: OverlappingEdges,
PlayRecording: playRecording,
Headless: headless,
RuneReplacements: RuneReplacements,
Width: width,
Height: height,
})
if err != nil {
return nil, err
}
return g, nil
}
func (gui *Gui) viewTabMap() map[string][]context.TabView {
titles := gui.sidePanelTabTitles()
result := map[string][]context.TabView{}
for _, panel := range gui.c.UserConfig().Gui.SidePanels {
if len(panel) < 2 {
// A single-tab panel shows its view's own title, not a tab strip.
continue
}
result[panel[0]] = lo.Map(panel, func(name string, _ int) context.TabView {
return context.TabView{
Tab: titles[name],
ViewName: sidePanelViewNames[name],
}
})
}
return result
}
// Run: setup the gui with keybindings and start the mainloop
func (gui *Gui) Run(startArgs appTypes.StartArgs) error {
g, err := gui.initGocui(Headless(), startArgs.IntegrationTest)
if err != nil {
return err
}
gui.g = g
defer gui.g.Close()
g.ErrorHandler = gui.PopupHandler.ErrorHandler
gui.g.ShouldHandleMouseEvent = func(view *gocui.View, key gocui.KeyName) bool {
if gui.helpers.Confirmation.IsPopupPanelFocused() && gui.currentViewName() != view.Name() &&
!gocui.IsMouseScrollKey(key) {
// we ignore click events on views that aren't popup panels, when a popup panel is focused.
// Unless both the current view and the clicked-on view are either commit message or commit
// description, or a prompt and the suggestions view, because we want to allow switching
// between those two views by clicking.
isCommitMessageOrSuggestionsView := func(viewName string) bool {
return viewName == "commitMessage" || viewName == "commitDescription" ||
viewName == "prompt" || viewName == "suggestions"
}
if !isCommitMessageOrSuggestionsView(gui.currentViewName()) || !isCommitMessageOrSuggestionsView(view.Name()) {
return false
}
}
return true
}
// if the deadlock package wants to report a deadlock, we first need to
// close the gui so that we can actually read what it prints.
deadlock.Opts.LogBuf = utils.NewOnceWriter(os.Stderr, func() {
gui.g.Close()
})
// disable deadlock reporting if we're not running in debug mode, or if
// we're debugging an integration test. In this latter case, stopping at
// breakpoints and stepping through code can easily take more than 30s.
deadlock.Opts.Disable = !gui.Debug || os.Getenv(components.WAIT_FOR_DEBUGGER_ENV_VAR) != ""
gui.g.OnSearchEscape = func() error { gui.helpers.Search.Cancel(); return nil }
gui.g.SetManager(gocui.ManagerFunc(gui.layout))
if err := gui.createAllViews(); err != nil {
return err
}
// onNewRepo must be called after g.SetManager because SetManager deletes keybindings
if err := gui.onNewRepo(startArgs, context.NO_CONTEXT); err != nil {
return err
}
gui.waitForIntro.Add(1)
gui.BackgroundRoutineMgr.startBackgroundRoutines()
gui.Helpers().SuspendResume.InstallResumeSignalHandler()
gui.c.Log.Info("starting main loop")
// setting here so we can use it in layout.go
gui.integrationTest = startArgs.IntegrationTest
err = gui.g.MainLoop()
if errors.Is(err, gocui.ErrQuit) {
// Give the focused context a chance to clean up before we tear down the app.
gui.c.Context().Current().HandleQuit()
}
return err
}
func (gui *Gui) RunAndHandleError(startArgs appTypes.StartArgs) error {
gui.stopChan = make(chan struct{})
return utils.SafeWithError(func() error {
if err := gui.Run(startArgs); err != nil {
for _, manager := range gui.viewBufferManagerMap {
manager.Close()
}
// The pty teardowns spawned by the manager closes above run on
// background goroutines that won't get to finish before the
// process exits; reap their process trees synchronously instead
// so that they don't outlive lazygit.
oscommands.TerminateLivePtys()
close(gui.stopChan)
if errors.Is(err, gocui.ErrQuit) {
if gui.c.State().GetRetainOriginalDir() {
if err := gui.helpers.RecordDirectory.RecordDirectory(gui.InitialDir); err != nil {
return err
}
} else {
if err := gui.helpers.RecordDirectory.RecordCurrentDirectory(); err != nil {
return err
}
}
return nil
}
return err
}
return nil
})
}
// returns whether command exited without error or not
func (gui *Gui) runSubprocessWithSuspenseAndRefresh(subprocess *oscommands.CmdObj) error {
_, err := gui.runSubprocessWithSuspense(subprocess)
if err != nil {
return err
}
gui.c.Refresh(types.RefreshOptions{DontBlockRepoSwitch: true})
return nil
}
func (gui *Gui) suspend() error {
if err := gui.g.Suspend(); err != nil {
return err
}
gui.BackgroundRoutineMgr.PauseBackgroundRefreshes(true)
return nil
}
func (gui *Gui) resume() error {
if err := gui.g.Resume(); err != nil {
return err
}
gui.BackgroundRoutineMgr.PauseBackgroundRefreshes(false)
return nil
}
// returns whether command exited without error or not
func (gui *Gui) runSubprocessWithSuspense(subprocess *oscommands.CmdObj) (bool, error) {
gui.Mutexes.SubprocessMutex.Lock()
defer gui.Mutexes.SubprocessMutex.Unlock()
if err := gui.suspend(); err != nil {
return false, err
}
cmdErr := gui.runSubprocess(subprocess)
if err := gui.resume(); err != nil {
return false, err
}
if cmdErr != nil {
return false, cmdErr
}
return true, nil
}
func (gui *Gui) runSubprocess(cmdObj *oscommands.CmdObj) error {
gui.LogCommand(cmdObj.ToString(), true)
subprocess := cmdObj.GetCmd()
subprocess.Stdout = os.Stdout
subprocess.Stderr = os.Stderr
subprocess.Stdin = os.Stdin
fmt.Fprintf(os.Stdout, "\n%s\n\n", style.FgBlue.Sprint("+ "+strings.Join(subprocess.Args, " ")))
err := subprocess.Run()
subprocess.Stdout = io.Discard
subprocess.Stderr = io.Discard
subprocess.Stdin = nil
if gui.integrationTest == nil && (gui.Config.GetUserConfig().PromptToReturnFromSubprocess || err != nil) {
fmt.Fprintf(os.Stdout, "\n%s", style.FgGreen.Sprint(gui.Tr.PressEnterToReturn))
// scan to buffer to prevent run unintentional operations when TUI resumes.
var buffer string
_, _ = fmt.Scanln(&buffer) // wait for enter press
}
return err
}
var isFirstRefreshAfterStartup = true
func (gui *Gui) loadNewRepo() error {
if err := gui.updateRecentRepoList(); err != nil {
return err
}
// On startup we don't want to block input during the initial refresh (it
// should be possible to press, say, `4` to jump to the commits panel right
// after startup without a delay), and we also want panels to show their
// contents as soon as possible; it doesn't matter so much that it's not in
// sync, we go from empty to populated here. However, when switching repos
// it can be confusing that some panels that are slow to update still show
// the old repo's data while others already show the new one's data, so
// update the UI only when everything is ready, and also block input to
// prevent accidentally trying to act on the old, stale data.
options := types.RefreshOptions{DontBlockRepoSwitch: true}
refresh := gui.c.Refresh
if isFirstRefreshAfterStartup {
isFirstRefreshAfterStartup = false
} else {
options.BatchUIUpdates = true
refresh = gui.c.RefreshBlockingInput
}
refresh(options)
if err := gui.os.UpdateWindowTitle(); err != nil {
return err
}
return nil
}
func (gui *Gui) showIntroPopupMessage() {
gui.waitForIntro.Add(1)
gui.c.OnUIThread(func() error {
onConfirm := func() error {
gui.c.GetAppState().StartupPopupVersion = StartupPopupVersion
err := gui.c.SaveAppState()
gui.waitForIntro.Done()
return err
}
introMessage := utils.ResolvePlaceholderString(
gui.c.Tr.IntroPopupMessage,
map[string]string{
"confirmationKey": gui.c.UserConfig().Keybinding.Universal.Confirm.String(),
},
)
gui.c.Confirm(types.ConfirmOpts{
Title: "",
Prompt: introMessage,
HandleConfirm: onConfirm,
HandleClose: onConfirm,
})
return nil
})
}
func (gui *Gui) showBreakingChangesMessage() {
_, err := types.ParseVersionNumber(gui.Config.GetVersion())
if err != nil {
// We don't have a parseable version, so we'll assume it's a developer
// build, or a build from HEAD with a version such as 0.40.0-g1234567;
// in these cases we don't show release notes.
return
}
last := &types.VersionNumber{}
lastVersionStr := gui.c.GetAppState().LastVersion
// If there's no saved last version, we show all release notes. This is for
// people upgrading from a version before we started to save lastVersion.
// First time new users won't see the release notes because we show them the
// intro popup instead.
if lastVersionStr != "" {
last, err = types.ParseVersionNumber(lastVersionStr)
if err != nil {
// The last version was a developer build, so don't show release
// notes in this case either.
return
}
}
// Now collect all release notes texts for versions newer than lastVersion.
// We don't need to bother checking the current version here, because we
// can't possibly have texts for versions newer than current.
type versionAndText struct {
version *types.VersionNumber
text string
}
texts := []versionAndText{}
for versionStr, text := range gui.Tr.BreakingChangesByVersion {
v, err := types.ParseVersionNumber(versionStr)
if err != nil {
// Ignore bogus entries in the BreakingChanges map
continue
}
if last.IsOlderThan(v) {
texts = append(texts, versionAndText{version: v, text: text})
}
}
if len(texts) > 0 {
sort.Slice(texts, func(i, j int) bool {
return texts[i].version.IsOlderThan(texts[j].version)
})
message := strings.Join(lo.Map(texts, func(t versionAndText, _ int) string { return t.text }), "\n")
gui.waitForIntro.Add(1)
gui.c.OnUIThread(func() error {
onConfirm := func() error {
gui.waitForIntro.Done()
return nil
}
gui.c.Confirm(types.ConfirmOpts{
Title: gui.Tr.BreakingChangesTitle,
Prompt: gui.Tr.BreakingChangesMessage + "\n\n" + message,
HandleConfirm: onConfirm,
HandleClose: onConfirm,
})
return nil
})
}
}
// setColorScheme sets the color scheme for the app based on the user config
func (gui *Gui) setColorScheme() {
userConfig := gui.UserConfig()
theme.UpdateTheme(userConfig.Gui.Theme)
gui.g.FgColor = theme.InactiveBorderColor
gui.g.SelFgColor = theme.ActiveBorderColor
gui.g.FrameColor = theme.InactiveBorderColor
gui.g.SelFrameColor = theme.ActiveBorderColor
}
func (gui *Gui) onUIThread(f func() error) {
gui.g.Update(func(*gocui.Gui) error {
return f()
})
}
func (gui *Gui) onUIThreadBackground(f func() error) {
gui.g.UpdateBackground(func(*gocui.Gui) error {
return f()
})
}
func (gui *Gui) onUIThreadContentOnly(f func() error) {
gui.g.UpdateContentOnly(func(*gocui.Gui) error {
return f()
})
}
func (gui *Gui) onUIThreadContentOnlyBackground(f func() error) {
gui.g.UpdateContentOnlyBackground(func(*gocui.Gui) error {
return f()
})
}
func (gui *Gui) onWorker(f func(gocui.Task) error) {
gui.g.OnWorker(f)
}
func (gui *Gui) onWorkerBackground(f func(gocui.Task) error) {
gui.g.OnWorkerBackground(f)
}
func (gui *Gui) getWindowDimensions(informationStr string, appStatus string) map[string]boxlayout.Dimensions {
return gui.helpers.WindowArrangement.GetWindowDimensions(informationStr, appStatus)
}
func (gui *Gui) afterLayout(f func() error) {
select {
case gui.afterLayoutFuncs <- f:
default:
// hopefully this never happens
gui.c.Log.Error("afterLayoutFuncs channel is full, skipping function")
}
}