Make the superproject selectable via the header

The header stands for the root repository: it is selected on startup,
shows the root's own state word, and the detail pane shows its state
like a submodule's (HEAD vs. upstream, changes, unpushed, leftover
submodule clones as untracked repositories). Submodules are ignored in
the root's changes; their rows show them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 11:54:40 +02:00
co-authored by Claude Sonnet 5.5
parent f9838c0c99
commit 72c060882b
18 changed files with 688 additions and 92 deletions
+7
View File
@@ -48,6 +48,10 @@ func parentOf(s *submodule.Submodule) *submodule.Submodule {
// visible any more, the closest visible one after it in display order
// becomes the selection, else the closest one before it.
func (m *Model) reselect(old *submodule.Submodule) {
if old != nil && old == m.root { // the superproject is never filtered out
m.cursor = old
return
}
visible := map[*submodule.Submodule]bool{}
var first *submodule.Submodule
for _, r := range m.rows() {
@@ -63,6 +67,9 @@ func (m *Model) reselect(old *submodule.Submodule) {
return
}
m.cursor = first
if first == nil {
m.cursor = m.root // nothing matches: the superproject remains
}
if old == nil || first == nil {
return
}
+62 -24
View File
@@ -23,6 +23,7 @@ const (
type Model struct {
id string // zone prefix, unique to this instance
theme theme.Theme
root *submodule.Submodule // the superproject: selectable, above the first row; may be nil
roots []*submodule.Submodule
expanded map[string]bool // keyed by Submodule.Path; kept when ancestors collapse
filter string // lower-case; empty if not filtering
@@ -37,12 +38,21 @@ func New(t theme.Theme) Model {
return Model{id: zone.NewPrefix(), theme: t, expanded: map[string]bool{}}
}
// SetRoots replaces the tree. All nodes start collapsed.
// SetRoot sets the superproject, which can be selected like a row above the
// first one. Call it before SetRoots / ReplaceRoots.
func (m *Model) SetRoot(root *submodule.Submodule) { m.root = root }
// SetRoots replaces the tree. All nodes start collapsed. The superproject is
// selected if there is one, else the first submodule.
func (m *Model) SetRoots(roots []*submodule.Submodule) {
m.roots = roots
m.expanded = map[string]bool{}
m.cursor = nil
m.offset = 0
if m.root != nil {
m.cursor = m.root
return
}
m.cursor = m.firstNode()
}
@@ -54,10 +64,14 @@ func (m *Model) ReplaceRoots(roots []*submodule.Submodule) {
if m.cursor != nil {
path = m.cursor.Path
}
wasRoot := m.cursor.IsRoot() // by path: SetRoot may have installed a new node
m.roots = roots
m.cursor = nil
if wasRoot && m.root != nil {
m.cursor = m.root
}
for _, r := range m.rows() {
if r.kind == rowNode && r.node.Path == path {
if m.cursor == nil && r.kind == rowNode && r.node.Path == path {
m.cursor = r.node
break
}
@@ -76,9 +90,19 @@ func (m *Model) SetSize(w, h int) {
// SetSpinner sets the frame drawn for submodules that are still loading.
func (m *Model) SetSpinner(frame string) { m.spinner = frame }
// Selected returns the submodule under the cursor, or nil.
// Selected returns the submodule under the cursor, or nil. It is the
// superproject (empty Path) while that is selected.
func (m Model) Selected() *submodule.Submodule { return m.cursor }
// RootSelected reports whether the superproject is selected.
func (m Model) RootSelected() bool { return m.onRoot() }
func (m Model) onRoot() bool { return m.root != nil && m.cursor == m.root }
// RootZone is the zone mark of the superproject's display; a click on it
// selects the superproject. The caller wraps the header in it.
func (m Model) RootZone() string { return m.id + "root" }
// --- navigation --------------------------------------------------------------
// Up selects the previous submodule row.
@@ -104,6 +128,10 @@ func (m *Model) Toggle() {
// Right expands the selected submodule and selects its first child.
func (m *Model) Right() {
if m.onRoot() {
m.Down()
return
}
if m.cursor == nil || len(m.cursor.Children) == 0 {
return
}
@@ -120,6 +148,9 @@ func (m *Model) Left() {
m.setExpanded(m.cursor, false)
case m.cursor.Parent != nil:
m.setExpanded(m.cursor.Parent, false)
case m.root != nil && !m.onRoot():
m.cursor = m.root // the superproject is the parent of the top level
m.offset = 0
}
}
@@ -179,24 +210,37 @@ func isAncestor(a, s *submodule.Submodule) bool {
}
// moveBy moves the cursor by n submodule rows, skipping directory headers
// and stopping at the ends.
// and stopping at the ends. The superproject counts as a row before the
// first one.
func (m *Model) moveBy(n int) {
rows := m.rows()
i := m.cursorRow(rows)
if i < 0 {
if m.onRoot() {
i = -1
} else if i < 0 {
return
}
step := 1
if n < 0 {
step, n = -1, -n
}
for j := i + step; n > 0 && j >= 0 && j < len(rows); j += step {
if rows[j].kind == rowNode {
for j := i + step; n > 0 && j >= -1 && j < len(rows); j += step {
switch {
case j == -1:
if m.root != nil {
i = -1
n--
}
case rows[j].kind == rowNode:
i = j
n--
}
}
m.cursor = rows[i].node
if i < 0 {
m.cursor = m.root
} else {
m.cursor = rows[i].node
}
m.clampOffset()
}
@@ -233,6 +277,10 @@ func (m *Model) clampOffset() {
if m.height <= 0 {
return
}
if m.onRoot() {
m.offset = 0
return
}
rows := m.rows()
i := m.cursorRow(rows)
if i < 0 {
@@ -269,6 +317,11 @@ func (m *Model) Update(msg tea.Msg) {
if mm.Action != tea.MouseActionPress || mm.Button != tea.MouseButtonLeft {
return
}
if m.root != nil && zone.Get(m.RootZone()).InBounds(mm) {
m.cursor = m.root
m.offset = 0
return
}
for _, r := range m.visibleRows() {
if r.kind != rowNode {
continue
@@ -416,22 +469,7 @@ func (m Model) nodeLine(r row, nameColW, refW int) string {
staged := s.Status != nil && s.Status.PinStaged
h := s.HiddenIssues()
stagedMark := staged && len(issues) > 0
var status string
switch {
case s.Err != nil:
status = t.LevelStyle(submodule.LevelDanger, base).Render("error")
case s.Status == nil:
status = seg(t.Primary, m.spinner)
case len(issues) > 0:
status = t.LevelStyle(issues[0].Level, base).Render(theme.IssueWord(issues[0].Kind))
if len(issues) > 1 {
status += seg(t.Muted, fmt.Sprintf(" +%d", len(issues)-1))
}
case staged:
status = seg(t.Info, theme.Staged)
default:
status = seg(t.Muted, theme.InSync)
}
status := t.StatusWord(s, base, m.spinner)
if stagedMark || collapsed && h.Staged > 0 {
status += pad(1) + seg(t.Info, t.Glyphs.Staged)
}
+106
View File
@@ -305,3 +305,109 @@ func equal(a, b []string) bool {
}
return true
}
// newTreeWithRoot is newTree with a selectable superproject.
func newTreeWithRoot(t *testing.T) *Model {
t.Helper()
m := New(theme.Default())
m.SetRoot(&submodule.Submodule{Name: "root"})
m.SetRoots(fixture())
m.SetSize(80, 20)
return &m
}
func TestRootIsSelectedInitially(t *testing.T) {
m := newTreeWithRoot(t)
if !m.RootSelected() {
t.Fatal("root not selected initially")
}
assertCursor(t, m, "")
}
func TestRootIsAboveFirstRow(t *testing.T) {
m := newTreeWithRoot(t)
m.Down()
assertCursor(t, m, "a")
if m.RootSelected() {
t.Error("root still selected after Down")
}
m.Up()
if !m.RootSelected() {
t.Error("Up from the first submodule should select the root")
}
m.Up() // stays
if !m.RootSelected() {
t.Error("Up on the root should stay on the root")
}
m.End()
assertCursor(t, m, "f")
m.Home()
if !m.RootSelected() {
t.Error("Home should select the root")
}
m.PageDown()
assertCursor(t, m, "f")
m.PageUp()
if !m.RootSelected() {
t.Error("PageUp should reach the root")
}
}
func TestRootNavigationKeys(t *testing.T) {
m := newTreeWithRoot(t)
m.Right() // like Down
assertCursor(t, m, "a")
m.Left() // top level, nothing to collapse: the parent is the root
if !m.RootSelected() {
t.Error("Left on a top-level submodule should select the root")
}
m.Left()
m.Toggle()
m.CollapseAll()
m.ExpandAll()
if !m.RootSelected() {
t.Error("fold keys must not move the root selection")
}
m.Down()
m.Down() // b
m.Right()
m.Right() // d
m.CollapseAll()
assertCursor(t, m, "b")
}
func TestReplaceRootsKeepsRootSelected(t *testing.T) {
m := newTreeWithRoot(t)
newRoot := &submodule.Submodule{Name: "root"}
m.SetRoot(newRoot)
m.ReplaceRoots(fixture())
if !m.RootSelected() || m.Selected() != newRoot {
t.Error("root selection should move to the reloaded root")
}
m.Down()
m.SetRoot(&submodule.Submodule{Name: "root"})
m.ReplaceRoots(fixture())
assertCursor(t, m, "a")
}
func TestFilterKeepsRoot(t *testing.T) {
m := newTreeWithRoot(t)
m.SetFilter("f")
if !m.RootSelected() {
t.Error("the root stays selected while filtering")
}
m.Down()
assertCursor(t, m, "f")
m.SetFilter("zzz") // nothing matches: the root remains, not nil
if !m.RootSelected() {
t.Error("no match should fall back to the root")
}
m.SetFilter("")
if !m.RootSelected() {
t.Error("closing the filter should keep the root")
}
}