mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-08-24 10:13:41 -05:00
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>
1303 lines
40 KiB
Go
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")
|
|
}
|
|
}
|