Archived
Add problems-only view (!) and draft the write actions
`!` reduces the tree to submodules with issues (or unknown state) plus their ancestors, reusing the filter's reduction. Adds docs/actions.md as a draft spec for restore and stage pin. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+24
-2
@@ -199,6 +199,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
} else {
|
||||
s.Status, s.Err = msg.status, nil
|
||||
}
|
||||
m.tree.Refit()
|
||||
}
|
||||
if m.loads.done(msg.path) {
|
||||
return m, m.loadStatus(msg.path)
|
||||
@@ -386,7 +387,10 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.filtering = true
|
||||
m.layout()
|
||||
return m, m.filter.Focus()
|
||||
case key.Matches(msg, k.Update, k.Bump, k.Issues):
|
||||
case key.Matches(msg, k.Issues):
|
||||
m.tree.SetProblemsOnly(!m.tree.ProblemsOnly())
|
||||
m.layout()
|
||||
case key.Matches(msg, k.Update, k.Bump):
|
||||
cmd := m.footer.Flash("“" + msg.String() + "” is not implemented in the prototype")
|
||||
m.layout()
|
||||
return m, cmd
|
||||
@@ -438,6 +442,9 @@ func (m Model) filterView() string {
|
||||
if !m.filter.Focused() {
|
||||
hint = "/ edit • esc close"
|
||||
}
|
||||
if m.tree.ProblemsOnly() {
|
||||
hint = "problems only (!) • " + hint
|
||||
}
|
||||
hint = theme.Fg(m.theme.Muted).Render(hint)
|
||||
m.filter.Width = max(m.width-lipgloss.Width(hint)-6, 1)
|
||||
in := m.filter.View()
|
||||
@@ -445,6 +452,19 @@ func (m Model) filterView() string {
|
||||
return lipgloss.NewStyle().Width(m.width).Padding(0, 1).Render(in + strings.Repeat(" ", gap) + hint)
|
||||
}
|
||||
|
||||
// extraLine reports whether a line above the footer is shown: the filter, or
|
||||
// the notice that only problems are shown.
|
||||
func (m Model) extraLine() bool { return m.filtering || m.tree.ProblemsOnly() }
|
||||
|
||||
// problemsView is the line above the footer while only problems are shown, so
|
||||
// a short list is not mistaken for a healthy repository.
|
||||
func (m Model) problemsView() string {
|
||||
left := theme.Fg(m.theme.Warn).Render("problems only")
|
||||
right := theme.Fg(m.theme.Muted).Render("! show all")
|
||||
gap := max(m.width-2-lipgloss.Width(left)-lipgloss.Width(right), 1)
|
||||
return lipgloss.NewStyle().Width(m.width).Padding(0, 1).Render(left + strings.Repeat(" ", gap) + right)
|
||||
}
|
||||
|
||||
// copyPath copies the absolute directory of the selected submodule to the
|
||||
// clipboard, or its path in the superproject if the provider can't tell.
|
||||
func (m Model) copyPath() (tea.Model, tea.Cmd) {
|
||||
@@ -470,7 +490,7 @@ func (m Model) copyPath() (tea.Model, tea.Cmd) {
|
||||
func (m *Model) layout() {
|
||||
m.footer.SetWidth(m.width)
|
||||
bodyHeight := m.height - headerHeight - lipgloss.Height(m.footer.View())
|
||||
if m.filtering {
|
||||
if m.extraLine() {
|
||||
bodyHeight--
|
||||
}
|
||||
bodyHeight = max(bodyHeight, 0)
|
||||
@@ -508,6 +528,8 @@ func (m Model) View() string {
|
||||
parts := []string{zone.Mark(m.tree.RootZone(), head), body}
|
||||
if m.filtering {
|
||||
parts = append(parts, m.filterView())
|
||||
} else if m.tree.ProblemsOnly() {
|
||||
parts = append(parts, m.problemsView())
|
||||
}
|
||||
return zone.Scan(lipgloss.JoinVertical(lipgloss.Left, append(parts, m.footer.View())...))
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
// SetFilter shows only the submodules whose path contains term (ignoring
|
||||
// case), plus their ancestors, which are shown expanded. An empty term
|
||||
// removes the filter.
|
||||
// removes the filter. It combines with SetProblemsOnly.
|
||||
//
|
||||
// If the selected submodule is filtered out, the next visible one is
|
||||
// selected (the previous one if there is none after it, none if nothing
|
||||
@@ -21,15 +21,46 @@ func (m *Model) SetFilter(term string) {
|
||||
}
|
||||
old := m.cursor
|
||||
m.filter = term
|
||||
if term == "" {
|
||||
m.narrow(old)
|
||||
}
|
||||
|
||||
// SetProblemsOnly shows only the submodules with issues, plus their
|
||||
// ancestors, which are shown expanded. A submodule whose status is loading or
|
||||
// failed to load counts as a problem, since its state is unknown. The
|
||||
// selection behaves as with SetFilter.
|
||||
func (m *Model) SetProblemsOnly(on bool) {
|
||||
if on == m.problemsOnly {
|
||||
return
|
||||
}
|
||||
old := m.cursor
|
||||
m.problemsOnly = on
|
||||
m.narrow(old)
|
||||
}
|
||||
|
||||
// ProblemsOnly reports whether only submodules with issues are shown.
|
||||
func (m Model) ProblemsOnly() bool { return m.problemsOnly }
|
||||
|
||||
// Refit keeps the selection visible after statuses changed, which can make a
|
||||
// submodule enter or leave the problems-only view.
|
||||
func (m *Model) Refit() {
|
||||
if m.problemsOnly {
|
||||
m.reselect(m.cursor)
|
||||
m.clampOffset()
|
||||
}
|
||||
}
|
||||
|
||||
// narrow fixes the selection after the filter or the problems-only view
|
||||
// changed.
|
||||
func (m *Model) narrow(old *submodule.Submodule) {
|
||||
if m.narrowed() {
|
||||
m.reselect(old)
|
||||
} else {
|
||||
for p := parentOf(old); p != nil; p = p.Parent {
|
||||
m.expanded[p.Path] = true
|
||||
}
|
||||
if m.cursor == nil {
|
||||
m.cursor = m.firstNode()
|
||||
}
|
||||
} else {
|
||||
m.reselect(old)
|
||||
}
|
||||
m.clampOffset()
|
||||
}
|
||||
@@ -37,6 +68,9 @@ func (m *Model) SetFilter(term string) {
|
||||
// Filter returns the active filter term in lower case.
|
||||
func (m Model) Filter() string { return m.filter }
|
||||
|
||||
// narrowed reports whether the tree is reduced to matches.
|
||||
func (m Model) narrowed() bool { return m.filter != "" || m.problemsOnly }
|
||||
|
||||
func parentOf(s *submodule.Submodule) *submodule.Submodule {
|
||||
if s == nil {
|
||||
return nil
|
||||
@@ -106,10 +140,10 @@ func (m *Model) appendAll(out *[]row, mods []*submodule.Submodule, depth int) {
|
||||
}
|
||||
}
|
||||
|
||||
// isExpanded reports whether the children of s are shown. While filtering,
|
||||
// isExpanded reports whether the children of s are shown. While narrowed,
|
||||
// that is the case if a match is below s, whatever the user expanded.
|
||||
func (m *Model) isExpanded(s *submodule.Submodule) bool {
|
||||
if m.filter == "" {
|
||||
if !m.narrowed() {
|
||||
return m.expanded[s.Path]
|
||||
}
|
||||
for _, c := range s.Children {
|
||||
@@ -122,7 +156,7 @@ func (m *Model) isExpanded(s *submodule.Submodule) bool {
|
||||
|
||||
// filtered returns the submodules of mods that pass the filter.
|
||||
func (m *Model) filtered(mods []*submodule.Submodule) []*submodule.Submodule {
|
||||
if m.filter == "" {
|
||||
if !m.narrowed() {
|
||||
return mods
|
||||
}
|
||||
var out []*submodule.Submodule
|
||||
@@ -136,7 +170,7 @@ func (m *Model) filtered(mods []*submodule.Submodule) []*submodule.Submodule {
|
||||
|
||||
// shown reports whether s matches the filter or has a descendant that does.
|
||||
func (m *Model) shown(s *submodule.Submodule) bool {
|
||||
if strings.Contains(strings.ToLower(s.Path), m.filter) {
|
||||
if strings.Contains(strings.ToLower(s.Path), m.filter) && (!m.problemsOnly || hasProblem(s)) {
|
||||
return true
|
||||
}
|
||||
for _, c := range s.Children {
|
||||
@@ -146,3 +180,9 @@ func (m *Model) shown(s *submodule.Submodule) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasProblem reports whether s has an issue or an unknown state (loading, or
|
||||
// failed to load, like HiddenIn counts it).
|
||||
func hasProblem(s *submodule.Submodule) bool {
|
||||
return s.Status == nil || len(s.Status.Issues()) > 0
|
||||
}
|
||||
|
||||
@@ -27,11 +27,13 @@ type Model struct {
|
||||
roots []*submodule.Submodule
|
||||
expanded map[string]bool // keyed by Submodule.Path; kept when ancestors collapse
|
||||
filter string // lower-case; empty if not filtering
|
||||
cursor *submodule.Submodule
|
||||
offset int // index of the first visible row
|
||||
width int
|
||||
height int
|
||||
spinner string
|
||||
// problemsOnly hides submodules without issues; it combines with filter.
|
||||
problemsOnly bool
|
||||
cursor *submodule.Submodule
|
||||
offset int // index of the first visible row
|
||||
width int
|
||||
height int
|
||||
spinner string
|
||||
}
|
||||
|
||||
func New(t theme.Theme) Model {
|
||||
@@ -365,8 +367,12 @@ func (m Model) View() string {
|
||||
lines = append(lines, m.nodeLine(r, nameColW, refW))
|
||||
}
|
||||
}
|
||||
if len(rows) == 0 && m.filter != "" {
|
||||
lines = append(lines, theme.Fg(m.theme.Muted).Width(m.width).Render(" no submodule matches"))
|
||||
if len(rows) == 0 && m.narrowed() {
|
||||
none := " no submodule matches"
|
||||
if m.filter == "" {
|
||||
none = " no problems"
|
||||
}
|
||||
lines = append(lines, theme.Fg(m.theme.Muted).Width(m.width).Render(none))
|
||||
}
|
||||
for len(lines) < m.height {
|
||||
lines = append(lines, strings.Repeat(" ", m.width))
|
||||
|
||||
@@ -411,3 +411,83 @@ func TestFilterKeepsRoot(t *testing.T) {
|
||||
t.Error("closing the filter should keep the root")
|
||||
}
|
||||
}
|
||||
|
||||
// healthy gives every submodule of the fixture an issue-free status, then
|
||||
// marks the given paths as changed.
|
||||
func healthy(t *testing.T, m *Model, changed ...string) {
|
||||
t.Helper()
|
||||
submodule.Walk(m.roots, func(s *submodule.Submodule) {
|
||||
s.Status = &submodule.Status{Pin: submodule.PinInfo{Pushed: true}}
|
||||
for _, p := range changed {
|
||||
if s.Path == p {
|
||||
s.Status.Worktree = submodule.WorktreeModified
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProblemsOnlyShowsIssuesWithAncestors(t *testing.T) {
|
||||
m := newTree(t)
|
||||
healthy(t, m, "d")
|
||||
m.SetProblemsOnly(true)
|
||||
want := []string{"src/", "b", "modules/", "c", "d"}
|
||||
if got := visible(m); !equal(got, want) {
|
||||
t.Fatalf("rows = %v, want %v", got, want)
|
||||
}
|
||||
assertCursor(t, m, "b") // a was selected and is healthy: next available
|
||||
m.Select("d")
|
||||
m.SetProblemsOnly(false)
|
||||
assertCursor(t, m, "d")
|
||||
if got := visible(m); len(got) != 8 {
|
||||
t.Fatalf("rows = %v, want all expanded to d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblemsOnlyKeepsUnknownStates(t *testing.T) {
|
||||
m := newTree(t)
|
||||
healthy(t, m)
|
||||
f := m.roots[2]
|
||||
f.Status, f.Err = nil, os.ErrNotExist // failed to load
|
||||
m.roots[0].Status = nil // still loading
|
||||
m.SetProblemsOnly(true)
|
||||
if got, want := visible(m), []string{"a", "src/", "f"}; !equal(got, want) {
|
||||
t.Fatalf("rows = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblemsOnlyWithoutProblemsKeepsRoot(t *testing.T) {
|
||||
m := New(theme.Default())
|
||||
m.SetRoot(&submodule.Submodule{Status: &submodule.Status{Root: true}})
|
||||
m.SetRoots(fixture())
|
||||
m.SetSize(80, 20)
|
||||
healthy(t, &m)
|
||||
m.SetProblemsOnly(true)
|
||||
if len(m.rows()) != 0 || !m.RootSelected() {
|
||||
t.Fatalf("rows %v, root selected %v", visible(&m), m.RootSelected())
|
||||
}
|
||||
m.Down() // must not panic
|
||||
}
|
||||
|
||||
func TestProblemsOnlyCombinesWithFilter(t *testing.T) {
|
||||
m := newTree(t)
|
||||
healthy(t, m, "d", "f")
|
||||
m.SetProblemsOnly(true)
|
||||
m.SetFilter("f")
|
||||
if got, want := visible(m), []string{"src/", "f"}; !equal(got, want) {
|
||||
t.Fatalf("rows = %v, want %v", got, want)
|
||||
}
|
||||
m.SetFilter("")
|
||||
if !m.ProblemsOnly() || len(visible(m)) != 6 {
|
||||
t.Fatalf("closing the filter keeps problems only: %v", visible(m))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefitMovesSelectionWhenProblemDisappears(t *testing.T) {
|
||||
m := newTree(t)
|
||||
healthy(t, m, "a", "f")
|
||||
m.SetProblemsOnly(true)
|
||||
assertCursor(t, m, "a")
|
||||
m.roots[0].Status.Worktree = submodule.WorktreeClean
|
||||
m.Refit()
|
||||
assertCursor(t, m, "f")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user