Files
lazygit/pkg/gui/views.go
T
Stefan HallerandClaude Opus 5 f802afa0ee Draw a gutter of inclusion marks over a view's content
The lines of a commit's diff that are in the custom patch being built have
to be shown as such over whatever a diff renderer made of that diff, whose
bytes we can't touch — and mustn't, since everything we know about a row is
keyed by where it sits in the content.

So the marks are a decoration drawn over the content instead: a column
reserved at the left of every line, blank but for the lines that are in,
with the content shifted along past it. Nothing about the content changes
but the width it has to wrap in.

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

323 lines
12 KiB
Go

package gui
import (
"errors"
"fmt"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/samber/lo"
)
type viewNameMapping struct {
viewPtr **gocui.View
name string
}
func (gui *Gui) orderedViews() []*gocui.View {
return lo.Map(gui.orderedViewNameMappings(), func(v viewNameMapping, _ int) *gocui.View {
return *v.viewPtr
})
}
func (gui *Gui) orderedViewNameMappings() []viewNameMapping {
return []viewNameMapping{
// first layer. Ordering within this layer does not matter because there are
// no overlapping views
{viewPtr: &gui.Views.Status, name: "status"},
{viewPtr: &gui.Views.Snake, name: "snake"},
{viewPtr: &gui.Views.Submodules, name: "submodules"},
{viewPtr: &gui.Views.Worktrees, name: "worktrees"},
{viewPtr: &gui.Views.Files, name: "files"},
{viewPtr: &gui.Views.Tags, name: "tags"},
{viewPtr: &gui.Views.Remotes, name: "remotes"},
{viewPtr: &gui.Views.Branches, name: "localBranches"},
{viewPtr: &gui.Views.RemoteBranches, name: "remoteBranches"},
{viewPtr: &gui.Views.ReflogCommits, name: "reflogCommits"},
{viewPtr: &gui.Views.Commits, name: "commits"},
{viewPtr: &gui.Views.Stash, name: "stash"},
{viewPtr: &gui.Views.SubCommits, name: "subCommits"},
{viewPtr: &gui.Views.CommitFiles, name: "commitFiles"},
{viewPtr: &gui.Views.Staging, name: "staging"},
{viewPtr: &gui.Views.StagingSecondary, name: "stagingSecondary"},
{viewPtr: &gui.Views.PatchBuilding, name: "patchBuilding"},
{viewPtr: &gui.Views.PatchBuildingSecondary, name: "patchBuildingSecondary"},
{viewPtr: &gui.Views.MergeConflicts, name: "mergeConflicts"},
{viewPtr: &gui.Views.Secondary, name: "secondary"},
{viewPtr: &gui.Views.Main, name: "main"},
{viewPtr: &gui.Views.Extras, name: "extras"},
// bottom line
{viewPtr: &gui.Views.Options, name: "options"},
{viewPtr: &gui.Views.AppStatus, name: "appStatus"},
{viewPtr: &gui.Views.Information, name: "information"},
{viewPtr: &gui.Views.Search, name: "search"},
// this view shows either the "Search:" prompt when searching, or the "Filter:" prompt when filtering
{viewPtr: &gui.Views.SearchPrefix, name: "searchPrefix"},
// these views contain one space, and are used as spacers between the various views in the bottom line
{viewPtr: &gui.Views.StatusSpacer1, name: "statusSpacer1"},
{viewPtr: &gui.Views.StatusSpacer2, name: "statusSpacer2"},
// popups.
{viewPtr: &gui.Views.CommitMessage, name: "commitMessage"},
{viewPtr: &gui.Views.CommitDescription, name: "commitDescription"},
{viewPtr: &gui.Views.Menu, name: "menu"},
// the filter row of a menu that filters as you type: a frame that hangs off
// the bottom of the menu and shows the "Filter:" prompt, plus the input
// field that sits inside it. Both must come after the menu so that the row's
// top border is drawn over the menu's bottom border.
{viewPtr: &gui.Views.MenuFilterFrame, name: "menuFilterFrame"},
{viewPtr: &gui.Views.MenuFilter, name: "menuFilter"},
{viewPtr: &gui.Views.Suggestions, name: "suggestions"},
{viewPtr: &gui.Views.Confirmation, name: "confirmation"},
{viewPtr: &gui.Views.Prompt, name: "prompt"},
{viewPtr: &gui.Views.Tooltip, name: "tooltip"},
// this guy will cover everything else when it appears
{viewPtr: &gui.Views.Limit, name: "limit"},
}
}
func (gui *Gui) createAllViews() error {
var err error
for _, mapping := range gui.orderedViewNameMappings() {
*mapping.viewPtr, err = gui.prepareView(mapping.name)
if err != nil && !errors.Is(err, gocui.ErrUnknownView) {
return err
}
}
gui.Views.Options.Frame = false
gui.Views.SearchPrefix.BgColor = gocui.ColorDefault
gui.Views.SearchPrefix.FgColor = gocui.ColorCyan
gui.Views.SearchPrefix.Frame = false
gui.Views.StatusSpacer1.Frame = false
gui.Views.StatusSpacer2.Frame = false
gui.Views.Search.BgColor = gocui.ColorDefault
gui.Views.Search.FgColor = gocui.ColorCyan
gui.Views.Search.Editable = true
gui.Views.Search.Frame = false
gui.Views.Search.Editor = gocui.EditorFunc(gui.searchEditor)
for _, view := range []*gocui.View{gui.Views.Main, gui.Views.Secondary, gui.Views.Staging, gui.Views.StagingSecondary, gui.Views.PatchBuilding, gui.Views.PatchBuildingSecondary, gui.Views.MergeConflicts} {
view.Wrap = true
view.UnderlineHyperLinksOnlyOnHover = true
view.AutoRenderHyperLinks = true
}
gui.Views.Staging.Wrap = true
gui.Views.StagingSecondary.Wrap = true
gui.Views.PatchBuilding.Wrap = true
gui.Views.PatchBuildingSecondary.Wrap = true
gui.Views.MergeConflicts.Wrap = false
gui.Views.Limit.Wrap = true
gui.Views.AppStatus.BgColor = gocui.ColorDefault
gui.Views.AppStatus.FgColor = gocui.ColorCyan
gui.Views.AppStatus.Visible = false
gui.Views.AppStatus.Frame = false
gui.Views.CommitMessage.Visible = false
gui.Views.CommitMessage.Editable = true
gui.Views.CommitMessage.Editor = gocui.EditorFunc(gui.commitMessageEditor)
gui.Views.CommitDescription.Visible = false
gui.Views.CommitDescription.Editable = true
gui.Views.CommitDescription.Editor = gocui.EditorFunc(gui.commitDescriptionEditor)
gui.Views.Confirmation.Visible = false
gui.Views.Confirmation.Wrap = true
gui.Views.Confirmation.AutoRenderHyperLinks = true
gui.Views.Prompt.Visible = false
gui.Views.Prompt.Wrap = false // We don't want wrapping in one-line prompts
gui.Views.Prompt.Editable = true
gui.Views.Prompt.Editor = gocui.EditorFunc(gui.promptEditor)
gui.Views.Suggestions.Visible = false
gui.Views.Menu.Visible = false
gui.Views.MenuFilterFrame.Visible = false
gui.Views.MenuFilter.Visible = false
gui.Views.MenuFilter.Frame = false
gui.Views.MenuFilter.Editable = true
gui.Views.MenuFilter.Editor = gocui.EditorFunc(gui.menuFilterEditor)
// The filter row belongs to the menu: it shares the menu's focus, and keys
// that the input field doesn't take are the menu's to handle.
gui.Views.MenuFilterFrame.ParentView = gui.Views.Menu
gui.Views.MenuFilter.ParentView = gui.Views.Menu
gui.Views.Tooltip.Visible = false
gui.Views.Tooltip.AutoRenderHyperLinks = true
gui.Views.Information.BgColor = gocui.ColorDefault
gui.Views.Information.FgColor = gocui.ColorGreen
gui.Views.Information.Frame = false
gui.Views.Extras.Autoscroll = true
gui.Views.Extras.Wrap = true
gui.Views.Extras.AutoRenderHyperLinks = true
gui.Views.Snake.FgColor = gocui.ColorGreen
// The main views show diffs, whose own colors say what each line is: which side of
// the diff it's on, and often its syntax highlighting too. A selection painted
// across the whole line takes those colors over, which for a whole selected hunk
// leaves one unreadable block; so mark the selection with a narrow bar at the left
// edge instead, and leave the rest of the line to the diff. Two columns, enough to
// read as a marker rather than as an artefact.
gui.Views.Main.SelectedLineColorWidth = 2
gui.Views.Secondary.SelectedLineColorWidth = 2
// A tick, for the lines of a commit's diff that are in the custom patch being
// built. A plus would collide with a diff's own plus column.
gui.Views.Main.InclusionGutterMarker = "✓"
gui.Views.Main.InclusionGutterMarkerColor = gocui.ColorGreen
gui.Views.Secondary.InclusionGutterMarker = "✓"
gui.Views.Secondary.InclusionGutterMarkerColor = gocui.ColorGreen
return nil
}
// gocui expects a view's frame runes in this order: the horizontal and the
// vertical edge, then the top left, top right, bottom left and bottom right
// corner.
func frameRunesWithTopCorners(frameRunes []rune, topLeft rune, topRight rune) []rune {
return []rune{frameRunes[0], frameRunes[1], topLeft, topRight, frameRunes[4], frameRunes[5]}
}
func (gui *Gui) configureViewProperties() {
frameRunes := []rune{'─', '│', '┌', '┐', '└', '┘'}
// The corners for a view that hangs off the bottom of another one, so that the
// border they share reads as a divider rather than as two frames touching.
teeLeft, teeRight := '├', '┤'
switch gui.c.UserConfig().Gui.Border {
case "double":
frameRunes = []rune{'═', '║', '╔', '╗', '╚', '╝'}
teeLeft, teeRight = '╠', '╣'
case "rounded":
frameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'}
case "hidden":
frameRunes = []rune{' ', ' ', ' ', ' ', ' ', ' '}
teeLeft, teeRight = ' ', ' '
case "bold":
frameRunes = []rune{'━', '┃', '┏', '┓', '┗', '┛'}
teeLeft, teeRight = '┣', '┫'
}
for _, mapping := range gui.orderedViewNameMappings() {
(*mapping.viewPtr).FrameRunes = frameRunes
(*mapping.viewPtr).BgColor = gui.g.BgColor
(*mapping.viewPtr).FgColor = theme.GocuiDefaultTextColor
(*mapping.viewPtr).SelBgColor = theme.GocuiSelectedLineBgColor
(*mapping.viewPtr).SelFgColor = gui.g.SelFgColor
(*mapping.viewPtr).InactiveViewSelBgColor = theme.GocuiInactiveViewSelectedLineBgColor
(*mapping.viewPtr).SelTextColor = theme.GocuiSelectedLineFgColor
}
gui.Views.MenuFilterFrame.FrameRunes = frameRunesWithTopCorners(frameRunes, teeLeft, teeRight)
gui.c.SetViewContent(gui.Views.SearchPrefix, gui.c.Tr.SearchPrefix)
gui.Views.Stash.Title = gui.c.Tr.StashTitle
gui.Views.Commits.Title = gui.c.Tr.CommitsTitle
gui.Views.ReflogCommits.Title = gui.c.Tr.ReflogCommitsTitle
gui.Views.CommitFiles.Title = gui.c.Tr.CommitFiles
gui.Views.Branches.Title = gui.c.Tr.BranchesTitle
gui.Views.Remotes.Title = gui.c.Tr.RemotesTitle
gui.Views.Worktrees.Title = gui.c.Tr.WorktreesTitle
gui.Views.Submodules.Title = gui.c.Tr.SubmodulesTitle
gui.Views.Tags.Title = gui.c.Tr.TagsTitle
gui.Views.Files.Title = gui.c.Tr.FilesTitle
gui.Views.PatchBuilding.Title = gui.c.Tr.Patch
gui.Views.PatchBuildingSecondary.Title = gui.c.Tr.CustomPatch
gui.Views.MergeConflicts.Title = gui.c.Tr.MergeConflictsTitle
gui.Views.Limit.Title = gui.c.Tr.NotEnoughSpace
gui.Views.Status.Title = gui.c.Tr.StatusTitle
gui.Views.Staging.Title = gui.c.Tr.UnstagedChanges
gui.Views.StagingSecondary.Title = gui.c.Tr.StagedChanges
gui.Views.CommitMessage.Title = gui.c.Tr.CommitSummary
gui.Views.CommitDescription.Title = gui.c.Tr.CommitDescriptionTitle
gui.Views.Extras.Title = gui.c.Tr.CommandLog
gui.Views.Snake.Title = gui.c.Tr.SnakeTitle
for _, view := range []*gocui.View{gui.Views.Main, gui.Views.Secondary, gui.Views.Staging, gui.Views.StagingSecondary, gui.Views.PatchBuilding, gui.Views.PatchBuildingSecondary, gui.Views.MergeConflicts} {
view.Title = gui.c.Tr.DiffTitle
view.CanScrollPastBottom = gui.c.UserConfig().Gui.ScrollPastBottom
view.TabWidth = gui.c.UserConfig().Gui.TabWidth
}
gui.Views.CommitDescription.FgColor = theme.GocuiDefaultTextColor
gui.Views.CommitDescription.TextArea.AutoWrap = gui.c.UserConfig().Git.Commit.AutoWrapCommitMessage
gui.Views.CommitDescription.TextArea.AutoWrapWidth = gui.c.UserConfig().Git.Commit.AutoWrapWidth
keyToTitlePrefix := func(binding config.Keybinding) string {
if len(binding) == 0 {
return ""
}
return fmt.Sprintf("[%s]", binding[0])
}
// The views that make up each side panel, in panel order. The whole group
// shares the panel's jump label.
panelViewGroups := lo.Map(gui.c.UserConfig().Gui.SidePanels, func(panel config.SidePanel, _ int) []*gocui.View {
return lo.Map(panel, func(name string, _ int) *gocui.View {
view, _ := gui.g.View(sidePanelViewNames[name])
return view
})
})
jumpBindings := gui.c.UserConfig().Keybinding.Universal.JumpToBlock
jumpLabelForPanel := func(panelIndex int) string {
if !gui.c.UserConfig().Gui.ShowPanelJumps || panelIndex >= len(jumpBindings) {
return ""
}
return keyToTitlePrefix(jumpBindings[panelIndex])
}
for panelIndex, views := range panelViewGroups {
prefix := jumpLabelForPanel(panelIndex)
for _, view := range views {
view.TitlePrefix = prefix
}
}
gui.focusMainViewJumpLabel = ""
if gui.c.UserConfig().Gui.ShowPanelJumps {
gui.focusMainViewJumpLabel = keyToTitlePrefix(gui.c.UserConfig().Keybinding.Universal.FocusMainView)
}
gui.showFocusMainViewJumpLabelOn(gui.Views.Main)
// Index the tab strips by view so we can both set them on views that are
// part of a multi-tab panel and clear them on views that no longer are
// (which matters when the config is reloaded and a tab becomes a standalone
// panel).
type viewTabs struct {
tabs []string
index int
}
tabsByView := map[string]viewTabs{}
for _, values := range gui.viewTabMap() {
labels := lo.Map(values, func(tabContext context.TabView, _ int) string {
return tabContext.Tab
})
for index, tabContext := range values {
tabsByView[tabContext.ViewName] = viewTabs{tabs: labels, index: index}
}
}
for _, view := range gui.g.Views() {
vt := tabsByView[view.Name()]
view.Tabs = vt.tabs
view.TabIndex = vt.index
}
}