Archived
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:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user