Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,22 @@ commit. If you have two independent refinements for the same target, make
two separate fixups. Reviewability of the intermediate state matters even
when the end state after autosquash would be identical.

## Surface mid-implementation decisions; decide them together

Planning can't anticipate everything. When a decision surfaces while you're
implementing — a design choice, a tradeoff, a scope cut, a "this turned out
harder than expected, so maybe X" — don't quietly make the call and keep
going, even if you have a clear recommendation and even if the call seems
small. Stop, lay out the options and your recommendation, and let me weigh in.
I want to make these calls _with_ you, not discover them after the fact in the
diff.

This isn't a request to stop and ask about every trivial detail; obvious
mechanical choices with one sensible answer don't need a checkpoint. It's about
genuine forks — the ones where a reasonable person might pick differently, or
where you'd be trading away something the plan assumed (scope, UX, performance,
reload behavior, …). When in doubt, surface it.

## Prefer the cleaner design over the smaller diff

When a task could be implemented either by tacking onto existing code or by
Expand Down
15 changes: 15 additions & 0 deletions docs-master/Config.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,21 @@ gui:
# is true.
expandedSidePanelWeight: 2

# The side panels, in the order they appear from top to bottom.
# Each entry is a list of one or more names that share a single panel as tabs
# (cycle through them with the next-tab/previous-tab keys).
# Omit a name to hide it; give a name its own one-element list to promote a tab
# to a top-level panel.
# Valid names are: 'status', 'files', 'worktrees', 'submodules', 'branches',
# 'remotes', 'tags', 'commits', 'reflog', 'stash'. 'files', 'branches', and
# 'commits' must always be included; they can't be hidden.
sidePanels:
- [status]
- [files, worktrees, submodules]
- [branches, remotes, tags]
- [commits, reflog]
- [stash]

# Sometimes the main window is split in two (e.g. when the selected file has
# both staged and unstaged changes). This setting controls how the two sections
# are split.
Expand Down
54 changes: 54 additions & 0 deletions pkg/config/side_panel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package config

import (
"github.com/karimkhaleel/jsonschema"
"github.com/samber/lo"
"gopkg.in/yaml.v3"
)

// SidePanel is one entry in gui.sidePanels: a side panel made up of one or more
// tabs, written in YAML as a list of tab names (e.g. [files, worktrees]).
type SidePanel []string

// ValidSidePanelTabs lists every name that may appear in gui.sidePanels. Each
// names a list that can stand alone as a panel or be grouped with others as the
// tabs of one panel. The resolver in the gui package must handle every entry
// here; a test enforces that the two stay in sync.
var ValidSidePanelTabs = []string{
"status",
"files",
"worktrees",
"submodules",
"branches",
"remotes",
"tags",
"commits",
"reflog",
"stash",
}

func (p SidePanel) MarshalYAML() (any, error) {
// Render in flow style (`[a, b]`) rather than the default block style, which
// is more compact and reads better in the generated docs.
node := &yaml.Node{
Kind: yaml.SequenceNode,
Style: yaml.FlowStyle,
}
for _, s := range p {
node.Content = append(node.Content, &yaml.Node{
Kind: yaml.ScalarNode,
Value: s,
})
}
return node, nil
}

// JSONSchema describes a side panel as a list of tab names, restricted to the
// known names.
func (SidePanel) JSONSchema() *jsonschema.Schema {
names := lo.Map(ValidSidePanelTabs, func(name string, _ int) any { return name })
return &jsonschema.Schema{
Type: "array",
Items: &jsonschema.Schema{Type: "string", Enum: names},
}
}
12 changes: 12 additions & 0 deletions pkg/config/user_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ type GuiConfig struct {
ExpandFocusedSidePanel bool `yaml:"expandFocusedSidePanel"`
// The weight of the expanded side panel, relative to the other panels. 2 means twice as tall as the other panels. Only relevant if `expandFocusedSidePanel` is true.
ExpandedSidePanelWeight int `yaml:"expandedSidePanelWeight"`
// The side panels, in the order they appear from top to bottom.
// Each entry is a list of one or more names that share a single panel as tabs (cycle through them with the next-tab/previous-tab keys).
// Omit a name to hide it; give a name its own one-element list to promote a tab to a top-level panel.
// Valid names are: 'status', 'files', 'worktrees', 'submodules', 'branches', 'remotes', 'tags', 'commits', 'reflog', 'stash'. 'files', 'branches', and 'commits' must always be included; they can't be hidden.
SidePanels []SidePanel `yaml:"sidePanels"`
// Sometimes the main window is split in two (e.g. when the selected file has both staged and unstaged changes). This setting controls how the two sections are split.
// Options are:
// - 'horizontal': split the window horizontally
Expand Down Expand Up @@ -839,6 +844,13 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
SidePanelWidth: 0.3333,
ExpandFocusedSidePanel: false,
ExpandedSidePanelWeight: 2,
SidePanels: []SidePanel{
{"status"},
{"files", "worktrees", "submodules"},
{"branches", "remotes", "tags"},
{"commits", "reflog"},
{"stash"},
},
MainPanelSplitMode: "flexible",
EnlargedSideViewLocation: "left",
WrapLinesInStagingView: true,
Expand Down
47 changes: 37 additions & 10 deletions pkg/config/user_config_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,42 @@ func (config *UserConfig) Validate() error {
if err := validateSpinner(config.Gui.Spinner); err != nil {
return err
}
if err := validateSidePanels(config.Gui.SidePanels); err != nil {
return err
}
return nil
}

func validateSidePanels(panels []SidePanel) error {
seen := map[string]bool{}
total := 0
for _, panel := range panels {
if len(panel) == 0 {
return errors.New("gui.sidePanels: a side panel must have at least one tab.")
}
for _, name := range panel {
if !slices.Contains(ValidSidePanelTabs, name) {
return fmt.Errorf("gui.sidePanels: unknown side panel '%s'. Allowed values: %s",
name, strings.Join(ValidSidePanelTabs, ", "))
}
if seen[name] {
return fmt.Errorf("gui.sidePanels: '%s' is listed more than once; each side panel may appear only once.", name)
}
seen[name] = true
total++
}
}
if total == 0 {
return errors.New("gui.sidePanels must not be empty.")
}
// A lot of code focuses these panels directly (e.g. after resolving a
// conflict or popping a stash), so they must always be present; otherwise
// that code would focus a hidden panel.
for _, required := range []string{"files", "branches", "commits"} {
if !seen[required] {
return fmt.Errorf("gui.sidePanels: '%s' must be included; it can't be hidden.", required)
}
}
return nil
}

Expand Down Expand Up @@ -141,16 +177,7 @@ func validateKeybindingsRecurse(path string, node any) error {
}

func validateKeybindings(keybindingConfig KeybindingConfig) error {
if err := validateKeybindingsRecurse("", keybindingConfig); err != nil {
return err
}

if len(keybindingConfig.Universal.JumpToBlock) != 5 {
return fmt.Errorf("keybinding.universal.jumpToBlock must have 5 elements; found %d.",
len(keybindingConfig.Universal.JumpToBlock))
}

return nil
return validateKeybindingsRecurse("", keybindingConfig)
}

func validateCustomCommandKey(key Keybinding) error {
Expand Down
43 changes: 40 additions & 3 deletions pkg/config/user_config_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,12 @@ func TestUserConfigValidate_enums(t *testing.T) {
})
},
testCases: []testCase{
{value: "", valid: false},
{value: "1,2,3", valid: false},
// The number of entries no longer has to match the number of side
// panels, so only the validity of the individual keys matters.
{value: "1,2,3", valid: true},
{value: "1,2,3,4,5", valid: true},
{value: "1,2,3,4,5,6", valid: true},
{value: "1,2,3,4,invalid", valid: false},
{value: "1,2,3,4,5,6", valid: false},
},
},
{
Expand Down Expand Up @@ -324,6 +325,42 @@ func TestUserConfigValidate_spinnerFrames(t *testing.T) {
}
}

func TestUserConfigValidate_sidePanels(t *testing.T) {
scenarios := []struct {
name string
panels []SidePanel
valid bool
}{
{name: "default layout", panels: []SidePanel{{"status"}, {"files", "worktrees", "submodules"}, {"branches", "remotes", "tags"}, {"commits", "reflog"}, {"stash"}}, valid: true},
{name: "reordered", panels: []SidePanel{{"status"}, {"files"}, {"commits"}, {"branches"}, {"stash"}}, valid: true},
{name: "hidden stash panel", panels: []SidePanel{{"status"}, {"files"}, {"branches"}, {"commits"}}, valid: true},
{name: "promoted tab", panels: []SidePanel{{"files", "submodules"}, {"worktrees"}, {"branches"}, {"commits"}}, valid: true},
{name: "core panels only", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}}, valid: true},
{name: "empty", panels: []SidePanel{}, valid: false},
{name: "empty panel", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}, {}}, valid: false},
{name: "unknown name", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}, {"bogus"}}, valid: false},
{name: "duplicate within panel", panels: []SidePanel{{"files", "files"}, {"branches"}, {"commits"}}, valid: false},
{name: "duplicate across panels", panels: []SidePanel{{"files"}, {"branches", "files"}, {"commits"}}, valid: false},
{name: "missing files", panels: []SidePanel{{"branches"}, {"commits"}}, valid: false},
{name: "missing branches", panels: []SidePanel{{"files"}, {"commits"}}, valid: false},
{name: "missing commits", panels: []SidePanel{{"files"}, {"branches"}}, valid: false},
}

for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
config := GetDefaultConfig()
config.Gui.SidePanels = s.panels
err := config.Validate()

if s.valid {
assert.NoError(t, err)
} else {
assert.Error(t, err)
}
})
}
}

func TestUserConfigValidate_pagers(t *testing.T) {
scenarios := []struct {
name string
Expand Down
2 changes: 2 additions & 0 deletions pkg/gocui/gui.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ type replayedEvents struct {
Keys chan *TcellKeyEventWrapper
Resizes chan *TcellResizeEventWrapper
MouseEvents chan *TcellMouseEventWrapper
FocusEvents chan *TcellFocusEventWrapper
}

type RecordingConfig struct {
Expand Down Expand Up @@ -245,6 +246,7 @@ func NewGui(opts NewGuiOpts) (*Gui, error) {
Keys: make(chan *TcellKeyEventWrapper),
Resizes: make(chan *TcellResizeEventWrapper),
MouseEvents: make(chan *TcellMouseEventWrapper),
FocusEvents: make(chan *TcellFocusEventWrapper),
}
}

Expand Down
18 changes: 18 additions & 0 deletions pkg/gocui/tcell_driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,22 @@ func (wrapper TcellResizeEventWrapper) toTcellEvent() tcell.Event {
return tcell.NewEventResize(wrapper.Width, wrapper.Height)
}

type TcellFocusEventWrapper struct {
Timestamp int64
Focused bool
}

func NewTcellFocusEventWrapper(event *tcell.EventFocus, timestamp int64) *TcellFocusEventWrapper {
return &TcellFocusEventWrapper{
Timestamp: timestamp,
Focused: event.Focused,
}
}

func (wrapper TcellFocusEventWrapper) toTcellEvent() tcell.Event {
return tcell.NewEventFocus(wrapper.Focused)
}

// pollEvent get tcell.Event and transform it into gocuiEvent
func (g *Gui) pollEvent() GocuiEvent {
var tev tcell.Event
Expand All @@ -277,6 +293,8 @@ func (g *Gui) pollEvent() GocuiEvent {
tev = (ev).toTcellEvent()
case ev := <-g.ReplayedEvents.MouseEvents:
tev = (ev).toTcellEvent()
case ev := <-g.ReplayedEvents.FocusEvents:
tev = (ev).toTcellEvent()
}
} else {
tev = <-Screen.EventQ()
Expand Down
Loading
Loading