Skip to content
Merged
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
491 changes: 482 additions & 9 deletions pkg/tui/app.go

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions pkg/tui/app_header_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,19 @@ func TestRenderHeader(t *testing.T) {
t.Errorf("header missing PRs tab")
}
}

func TestConfirmTwice(t *testing.T) {
m := &Model{}
if m.confirmTwice("del:x") {
t.Fatalf("first press should arm, not confirm")
}
if !m.confirmTwice("del:x") {
t.Fatalf("second press should confirm")
}
if m.confirmTwice("del:x") {
t.Fatalf("after confirm, next press re-arms")
}
if m.confirmTwice("del:y") {
t.Fatalf("different action should arm separately")
}
}
6 changes: 6 additions & 0 deletions pkg/tui/app_palette.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ func (m *Model) executePaletteCommand(id string) (tea.Model, tea.Cmd) {
m.focusedPanel = (m.focusedPanel - 1 + panelCount) % panelCount
case "dashboard":
m.openScreen(shell.Dashboard)
case "worktrees-view":
m.openScreen(shell.Worktrees)
case "stash-view":
m.openScreen(shell.Stash)
case "refs-view":
m.openScreen(shell.Refs)
case "actions-view":
m.openScreen(shell.Actions)
case "notifications":
Expand Down
8 changes: 7 additions & 1 deletion pkg/tui/shell/route.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ const (
Dashboard ScreenID = 5
Actions ScreenID = 6
Notes ScreenID = 7
Worktrees ScreenID = 8
Stash ScreenID = 9
Refs ScreenID = 10
)

// Tab binds a screen to its header label and hotkey.
Expand All @@ -24,14 +27,17 @@ type Tab struct {
Key string
}

// Tabs is the single source of truth for the header strip, the 1–7
// Tabs is the single source of truth for the header strip, the number
// keys, and palette navigation dispatch.
var Tabs = []Tab{
{ID: PRs, Label: "PRs", Key: "3"},
{ID: Issues, Label: "Issues", Key: "4"},
{ID: Dashboard, Label: "Repos", Key: "5"},
{ID: Actions, Label: "Actions", Key: "6"},
{ID: Notes, Label: "Notes", Key: "7"},
{ID: Worktrees, Label: "Worktrees", Key: "8"},
{ID: Stash, Label: "Stash", Key: "9"},
{ID: Refs, Label: "Refs", Key: "0"},
}

// Short returns the compact tab label for narrow terminals ("5R").
Expand Down
17 changes: 13 additions & 4 deletions pkg/tui/shell/shell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ package shell
import "testing"

func TestRouteTable(t *testing.T) {
if len(Tabs) != 5 {
t.Fatalf("tabs = %d, want 5", len(Tabs))
if len(Tabs) != 8 {
t.Fatalf("tabs = %d, want 8", len(Tabs))
}
seenKeys := map[string]bool{}
seenIDs := map[ScreenID]bool{}
Expand All @@ -30,8 +30,17 @@ func TestRouteTable(t *testing.T) {
if ByKey("5") == nil || ByKey("5").ID != Dashboard {
t.Errorf("ByKey(5) should be Dashboard")
}
if ByKey("9") != nil {
t.Errorf("ByKey(9) should be nil")
if ByKey("8") == nil || ByKey("8").ID != Worktrees {
t.Errorf("ByKey(8) should be Worktrees")
}
if ByKey("9") == nil || ByKey("9").ID != Stash {
t.Errorf("ByKey(9) should be Stash")
}
if ByKey("0") == nil || ByKey("0").ID != Refs {
t.Errorf("ByKey(0) should be Refs")
}
if ByKey("2") != nil {
t.Errorf("ByKey(2) should be nil (panel key, not a tab)")
}
if Known(Panels) {
t.Errorf("Panels is not a fullscreen tab")
Expand Down
39 changes: 39 additions & 0 deletions pkg/tui/views/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ func allSections() []helpSection {
{"5", "Open Repositories dashboard (enter switches repo)"},
{"6", "Open Actions view (enter drills into jobs)"},
{"7", "Open Notifications inbox (x marks read)"},
{"8", "Open Worktrees manager"},
{"9", "Open Stash manager"},
{"0", "Open Tags & Remotes"},
{"Tab", "Back to panels from any full-screen view"},
},
},
Expand Down Expand Up @@ -101,6 +104,42 @@ func allSections() []helpSection {
{"Tab", "Back to panels"},
},
},
{
title: "Worktrees",
keys: []helpEntry{
{"/", "Filter worktree list"},
{"a", "Add worktree (prompts path)"},
{"D", "Remove worktree (press twice)"},
{"u", "Lock / unlock worktree"},
{"r", "Refresh"},
{"Tab", "Back to panels"},
},
},
{
title: "Stash",
keys: []helpEntry{
{"/", "Filter stash list"},
{"s", "Push stash (prompts message)"},
{"a", "Apply selected stash"},
{"p", "Pop selected stash"},
{"D", "Drop stash (press twice)"},
{"r", "Refresh"},
{"Tab", "Back to panels"},
},
},
{
title: "Refs (tags & remotes)",
keys: []helpEntry{
{"t", "Toggle tags / remotes section"},
{"/", "Filter focused section"},
{"c", "Create tag (tags section)"},
{"D", "Delete tag (press twice)"},
{"f", "Fetch remote"},
{"F", "Fetch remote with prune"},
{"r", "Refresh"},
{"Tab", "Back to panels"},
},
},
{
title: "Actions (full-screen)",
keys: []helpEntry{
Expand Down
81 changes: 81 additions & 0 deletions pkg/tui/views/newviews_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,84 @@ func TestConfigStepCompletion(t *testing.T) {
t.Fatalf("provider passthrough = %q", m.GetSelectedProvider())
}
}

func TestWorktreeFiltered(t *testing.T) {
m := NewWorktreeModel()
m.UpdateWorktrees([]*models.Worktree{
{Path: "/r/main", Branch: "dev", IsMain: true},
{Path: "/r/feat", Branch: "feat/x"},
})
m.Filter = "feat"
if got := m.Filtered(); len(got) != 1 || got[0].Branch != "feat/x" {
t.Fatalf("filtered = %+v", got)
}
if out := m.View(100); !strings.Contains(out, "Worktrees (1/2)") {
t.Fatalf("title missing:\n%s", out)
}
}

func TestStashFiltered(t *testing.T) {
m := NewStashModel()
m.UpdateStash([]*models.Stash{
{Index: 0, Message: "wip login"},
{Index: 1, Message: "experiment"},
})
m.Filter = "login"
if got := m.Filtered(); len(got) != 1 || got[0].Index != 0 {
t.Fatalf("filtered = %+v", got)
}
}

func TestRefsSections(t *testing.T) {
m := NewRefsModel()
m.UpdateRefs([]string{"v1.0", "v2.0"}, []*models.Remote{
{Name: "origin", URL: "git@github.com:o/r.git"},
})
if m.Section != RefsTags {
t.Fatalf("default section should be tags")
}
m.Toggle()
if m.Section != RefsRemotes {
t.Fatalf("toggle should flip section")
}
// Filter applies per-section lists; tags list matches one.
m.Section = RefsTags
m.Tags.Filter = "v1"
if got := m.Tags.Filtered(); len(got) != 1 {
t.Fatalf("tags filtered = %v", got)
}
if out := m.View(100); !strings.Contains(out, "origin") {
t.Fatalf("remotes section missing:\n%s", out)
}
}

func TestPromptKeys(t *testing.T) {
var p Prompt
if done, cancel := p.TypeKey("a"); done || cancel {
t.Fatalf("closed prompt should ignore keys")
}
p.Open("name: ")
for _, k := range []string{"v", "1", ".", "0"} {
if done, cancel := p.TypeKey(k); done || cancel {
t.Fatalf("typing should not finish")
}
}
if p.Value != "v1.0" {
t.Fatalf("value = %q", p.Value)
}
p.TypeKey("backspace")
if p.Value != "v1." {
t.Fatalf("after backspace = %q", p.Value)
}
p.TypeKey("0")
if done, _ := p.TypeKey("enter"); !done || !p.Active && p.Value != "v1.0" {
t.Fatalf("enter should finish")
}
p.Open("x: ")
if _, cancel := p.TypeKey("esc"); !cancel || p.Active {
t.Fatalf("esc should cancel")
}
if !strings.Contains(p.View(), "") {
t.Fatalf("closed view should be empty, got %q", p.View())
}
}
3 changes: 3 additions & 0 deletions pkg/tui/views/palette.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ func (m *PaletteModel) SetDefaultCommands() {
{ID: "next-panel", Label: "Next Panel", Description: "Cycle focus to next panel", Keys: "Tab"},
{ID: "prev-panel", Label: "Prev Panel", Description: "Cycle focus to previous panel", Keys: "S-Tab"},
{ID: "dashboard", Label: "Repositories", Description: "Open repository dashboard", Keys: "5"},
{ID: "worktrees-view", Label: "Worktrees", Description: "Open worktree manager", Keys: "8"},
{ID: "stash-view", Label: "Stash", Description: "Open stash manager", Keys: "9"},
{ID: "refs-view", Label: "Refs", Description: "Open tags & remotes", Keys: "0"},
{ID: "actions-view", Label: "Actions", Description: "Open CI workflow runs", Keys: "6"},
{ID: "notifications", Label: "Notifications", Description: "Open GitHub inbox", Keys: "7"},
{ID: "prs-view", Label: "Pull Requests", Description: "Open pull request list", Keys: "3"},
Expand Down
57 changes: 57 additions & 0 deletions pkg/tui/views/prompt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package views

import (
"github.com/zaidejjo/zgit/pkg/tui/styles"
)

// Prompt is a one-line inline input row used for quick add-flows
// (new worktree path, stash message, tag name). Lighter than a huh
// dialog: it renders inside the list view and submits on enter.
type Prompt struct {
Active bool
Label string
Value string
}

// Open shows the prompt with a label.
func (p *Prompt) Open(label string) {
p.Active = true
p.Label = label
p.Value = ""
}

// TypeKey routes typing. Returns done (enter with any value),
// cancelled (esc), consumed.
// Empty submit is allowed — callers decide requiredness.
func (p *Prompt) TypeKey(key string) (done, cancelled bool) {
if !p.Active {
return false, false
}
switch key {
case "enter":
p.Active = false
return true, false
case "esc":
p.Active = false
p.Value = ""
return false, true
case "backspace":
if len(p.Value) > 0 {
p.Value = p.Value[:len(p.Value)-1]
}
return false, false
default:
if len(key) == 1 || key == " " {
p.Value += key
}
return false, false
}
}

// View renders the prompt row, or "" when closed.
func (p *Prompt) View() string {
if !p.Active {
return ""
}
return styles.FilterBarStyle.Render(" "+p.Label+" "+p.Value+"█") + "\n"
}
Loading
Loading