diff --git a/pkg/tui/app.go b/pkg/tui/app.go index 97adf7e..e53eb7f 100644 --- a/pkg/tui/app.go +++ b/pkg/tui/app.go @@ -65,15 +65,18 @@ type Model struct { pushAfterCommit bool // if true, push after commit succeeds // Sub-models (views) - status views.StatusModel - log views.LogModel - branches views.BranchModel - prs views.PRListModel - issues views.IssueListModel - dash views.DashModel - actions views.ActionsModel - notes views.NotesModel - helpView views.HelpModel + status views.StatusModel + log views.LogModel + branches views.BranchModel + prs views.PRListModel + issues views.IssueListModel + dash views.DashModel + actions views.ActionsModel + notes views.NotesModel + worktrees views.WorktreeModel + stash views.StashModel + refs views.RefsModel + helpView views.HelpModel // Overlay models diffViewer views.DiffModel @@ -90,6 +93,10 @@ type Model struct { // Transient notifications toasts components.Toasts + // Double-press destructive guard + armedAction string + armedAt time.Time + // Terminal dimensions width int height int @@ -126,6 +133,9 @@ func NewModel(gitExec *gitpkg.NativeExec) *Model { dash: views.NewDashModel(), actions: views.NewActionsModel(), notes: views.NewNotesModel(), + worktrees: views.NewWorktreeModel(), + stash: views.NewStashModel(), + refs: views.NewRefsModel(), helpView: views.HelpModel{}, diffViewer: views.NewDiffModel(), commitDlg: views.NewCommitModel(), @@ -495,6 +505,14 @@ func (m *Model) renderHeader() string { if m.actions.Items != nil { label = fmt.Sprintf("%s (%d)", label, len(m.actions.Items)) } + case shell.Worktrees: + if m.worktrees.Loaded { + label = fmt.Sprintf("%s (%d)", label, len(m.worktrees.Items)) + } + case shell.Stash: + if m.stash.Loaded { + label = fmt.Sprintf("%s (%d)", label, len(m.stash.Items)) + } } } if t.ID == shell.Notes && m.notes.Loaded && m.notes.UnreadCount() > 0 { @@ -542,6 +560,12 @@ func (m *Model) renderContent() string { return m.issues.View(m.width - 2) case shell.Dashboard: return m.dash.View(m.width - 2) + case shell.Worktrees: + return m.worktrees.View(m.width - 2) + case shell.Stash: + return m.stash.View(m.width - 2) + case shell.Refs: + return m.refs.View(m.width - 2) case shell.Actions: return m.actions.View(m.width - 2) case shell.Notes: @@ -689,6 +713,7 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "R": m.sub.Refresh() m.refreshGitHubData() + m.reloadLocal() return m, nil // Tab cycles focus: Status → Log → Branches → Status @@ -788,6 +813,15 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.fullScreenView == shell.Notes { return m.handleNotesKeys(key) } + if m.fullScreenView == shell.Worktrees { + return m.handleWorktreeKeys(key) + } + if m.fullScreenView == shell.Stash { + return m.handleStashKeys(key) + } + if m.fullScreenView == shell.Refs { + return m.handleRefsKeys(key) + } // Delegate to focused panel switch m.focusedPanel { @@ -838,6 +872,18 @@ func (m *Model) openScreen(id shell.ScreenID) { if !m.dash.Loaded { go m.fetchDashboard() } + case shell.Worktrees: + if !m.worktrees.Loaded { + go m.fetchWorktrees() + } + case shell.Stash: + if !m.stash.Loaded { + go m.fetchStash() + } + case shell.Refs: + if !m.refs.Loaded { + go m.fetchRefs() + } default: m.ensureGHDataLoaded() } @@ -1028,6 +1074,93 @@ func (m *Model) fetchActionJobs(runID int64) { m.msgs <- teaMsg{view: 6, data: actionJobsEvent{runID: runID, jobs: jobs}} } +// fetchWorktrees loads linked working trees. +func (m *Model) fetchWorktrees() { + if m.git == nil { + m.msgs <- teaMsg{view: 8, data: worktreesEvent{err: fmt.Errorf("no git backend")}} + return + } + ctx, cancel := context.WithTimeout(context.Background(), 15e9) + defer cancel() + items, err := m.git.WorktreeList(ctx) + if err != nil { + m.msgs <- teaMsg{view: 8, data: worktreesEvent{err: err}} + return + } + if items == nil { + items = []*models.Worktree{} + } + m.msgs <- teaMsg{view: 8, data: worktreesEvent{items: items}} +} + +// fetchStash loads stash entries. +func (m *Model) fetchStash() { + if m.git == nil { + m.msgs <- teaMsg{view: 9, data: stashEvent{err: fmt.Errorf("no git backend")}} + return + } + ctx, cancel := context.WithTimeout(context.Background(), 15e9) + defer cancel() + items, err := m.git.StashList(ctx) + if err != nil { + m.msgs <- teaMsg{view: 9, data: stashEvent{err: err}} + return + } + if items == nil { + items = []*models.Stash{} + } + m.msgs <- teaMsg{view: 9, data: stashEvent{items: items}} +} + +// fetchRefs loads tags and remotes (deduped by name). +func (m *Model) fetchRefs() { + if m.git == nil { + m.msgs <- teaMsg{view: 10, data: refsEvent{err: fmt.Errorf("no git backend")}} + return + } + ctx, cancel := context.WithTimeout(context.Background(), 15e9) + defer cancel() + tags, err := m.git.TagList(ctx) + if err != nil { + m.msgs <- teaMsg{view: 10, data: refsEvent{err: err}} + return + } + remotes, err := m.git.RemoteList(ctx) + if err != nil { + m.msgs <- teaMsg{view: 10, data: refsEvent{err: err}} + return + } + seen := map[string]bool{} + uniq := make([]*models.Remote, 0, len(remotes)) + for _, r := range remotes { + if r == nil || seen[r.Name] { + continue + } + seen[r.Name] = true + uniq = append(uniq, r) + } + m.msgs <- teaMsg{view: 10, data: refsEvent{tags: tags, remotes: uniq}} +} + +// reloadLocal refreshes the local-git screens (cheap, no network). +func (m *Model) reloadLocal() { + go m.fetchWorktrees() + go m.fetchStash() + go m.fetchRefs() +} + +// confirmTwice arms a destructive action on first press; the second +// press within 3s confirms. Callers toast the "press again" hint. +func (m *Model) confirmTwice(action string) bool { + if m.armedAction == action && time.Since(m.armedAt) < 3*time.Second { + m.armedAction = "" + return true + } + m.armedAction = action + m.armedAt = time.Now() + return false +} + // fetchNotes loads the global notification inbox. func (m *Model) fetchNotes() { if m.gh == nil { @@ -1505,6 +1638,295 @@ func (m *Model) handleNotesKeys(key string) (tea.Model, tea.Cmd) { return m, nil } +func (m *Model) handleWorktreeKeys(key string) (tea.Model, tea.Cmd) { + if m.worktrees.Prompt.Active { + if done, _ := m.worktrees.Prompt.TypeKey(key); done { + path := strings.TrimSpace(m.worktrees.Prompt.Value) + if path == "" { + m.worktrees.Prompt.Open("new worktree path: ") + return m, m.pushToast("path is required", components.ToneInfo) + } + return m, m.addWorktree(path) + } + return m, nil + } + if m.worktrees.TypeKey(key) { + return m, nil + } + switch key { + case "/": + m.worktrees.Filtering = true + return m, nil + case "a": + m.worktrees.Prompt.Open("new worktree path: ") + return m, nil + case "D": + sel, ok := m.worktrees.Selected() + if !ok { + return m, nil + } + if sel.IsMain { + return m, m.pushToast("cannot remove the main worktree", components.ToneInfo) + } + if !m.confirmTwice("wt-del:" + sel.Path) { + return m, m.pushToast(fmt.Sprintf("press D again to remove %s", sel.Path), components.ToneInfo) + } + return m, m.removeWorktree(sel.Path) + case "u": + sel, ok := m.worktrees.Selected() + if !ok { + return m, nil + } + return m, m.toggleWorktreeLock(sel) + case "r": + m.worktrees.Loaded = false + go m.fetchWorktrees() + return m, nil + default: + m.worktrees.Nav(key) + } + return m, nil +} + +func (m *Model) addWorktree(path string) tea.Cmd { + ctx, cancel := context.WithTimeout(context.Background(), 30e9) + defer cancel() + if err := m.git.WorktreeAdd(ctx, path, "", models.WorktreeAddOptions{}); err != nil { + return m.pushToast(fmt.Sprintf("add failed: %v", err), components.ToneError) + } + go m.fetchWorktrees() + return m.pushToast("Worktree added", components.ToneSuccess) +} + +func (m *Model) removeWorktree(path string) tea.Cmd { + ctx, cancel := context.WithTimeout(context.Background(), 30e9) + defer cancel() + if err := m.git.WorktreeRemove(ctx, path, false); err != nil { + return m.pushToast(fmt.Sprintf("remove failed: %v", err), components.ToneError) + } + go m.fetchWorktrees() + return m.pushToast("Worktree removed", components.ToneSuccess) +} + +func (m *Model) toggleWorktreeLock(w *models.Worktree) tea.Cmd { + ctx, cancel := context.WithTimeout(context.Background(), 15e9) + defer cancel() + var err error + if w.Locked { + err = m.git.WorktreeUnlock(ctx, w.Path) + } else { + err = m.git.WorktreeLock(ctx, w.Path, "") + } + if err != nil { + return m.pushToast(fmt.Sprintf("lock failed: %v", err), components.ToneError) + } + go m.fetchWorktrees() + if w.Locked { + return m.pushToast("Worktree unlocked", components.ToneSuccess) + } + return m.pushToast("Worktree locked", components.ToneSuccess) +} + +func (m *Model) handleStashKeys(key string) (tea.Model, tea.Cmd) { + if m.stash.Prompt.Active { + if done, _ := m.stash.Prompt.TypeKey(key); done { + msg := strings.TrimSpace(m.stash.Prompt.Value) + return m, m.pushStash(msg) + } + return m, nil + } + if m.stash.TypeKey(key) { + return m, nil + } + switch key { + case "/": + m.stash.Filtering = true + return m, nil + case "s": + m.stash.Prompt.Open("stash message (empty ok): ") + return m, nil + case "a", "p": + sel, ok := m.stash.Selected() + if !ok { + return m, nil + } + return m, m.applyStash(sel.Index, key == "p") + case "D": + sel, ok := m.stash.Selected() + if !ok { + return m, nil + } + if !m.confirmTwice(fmt.Sprintf("stash-drop:%d", sel.Index)) { + return m, m.pushToast(fmt.Sprintf("press D again to drop stash@{%d}", sel.Index), components.ToneInfo) + } + return m, m.dropStash(sel.Index) + case "r": + m.stash.Loaded = false + go m.fetchStash() + return m, nil + default: + m.stash.Nav(key) + } + return m, nil +} + +func (m *Model) pushStash(msg string) tea.Cmd { + ctx, cancel := context.WithTimeout(context.Background(), 30e9) + defer cancel() + if err := m.git.StashPush(ctx, gitpkg.StashOptions{}, msg); err != nil { + return m.pushToast(fmt.Sprintf("stash failed: %v", err), components.ToneError) + } + m.sub.Refresh() + go m.fetchStash() + return m.pushToast("Stashed", components.ToneSuccess) +} + +func (m *Model) applyStash(index int, pop bool) tea.Cmd { + ctx, cancel := context.WithTimeout(context.Background(), 30e9) + defer cancel() + var err error + if pop { + err = m.git.StashPop(ctx, index) + } else { + err = m.git.StashApply(ctx, index) + } + if err != nil { + verb := "apply" + if pop { + verb = "pop" + } + return m.pushToast(fmt.Sprintf("%s failed: %v", verb, err), components.ToneError) + } + m.sub.Refresh() + go m.fetchStash() + if pop { + return m.pushToast("Stash popped", components.ToneSuccess) + } + return m.pushToast("Stash applied", components.ToneSuccess) +} + +func (m *Model) dropStash(index int) tea.Cmd { + ctx, cancel := context.WithTimeout(context.Background(), 15e9) + defer cancel() + if err := m.git.StashDrop(ctx, index); err != nil { + return m.pushToast(fmt.Sprintf("drop failed: %v", err), components.ToneError) + } + go m.fetchStash() + return m.pushToast("Stash dropped", components.ToneSuccess) +} + +func (m *Model) handleRefsKeys(key string) (tea.Model, tea.Cmd) { + if m.refs.Prompt.Active { + if done, _ := m.refs.Prompt.TypeKey(key); done { + name := strings.TrimSpace(m.refs.Prompt.Value) + if name == "" { + m.refs.Prompt.Open("new tag name: ") + return m, m.pushToast("name is required", components.ToneInfo) + } + return m, m.createTag(name) + } + return m, nil + } + tags := m.refs.Section == views.RefsTags + if tags && m.refs.Tags.TypeKey(key) { + return m, nil + } + if !tags && m.refs.Remotes.TypeKey(key) { + return m, nil + } + switch key { + case "t": + m.refs.Toggle() + return m, nil + case "/": + if tags { + m.refs.Tags.Filtering = true + } else { + m.refs.Remotes.Filtering = true + } + return m, nil + case "c": + if !tags { + return m, nil + } + m.refs.Prompt.Open("new tag name: ") + return m, nil + case "D": + if !tags { + return m, nil + } + sel, ok := m.refs.Tags.Selected() + if !ok { + return m, nil + } + if !m.confirmTwice("tag-del:" + sel) { + return m, m.pushToast(fmt.Sprintf("press D again to delete tag %s", sel), components.ToneInfo) + } + return m, m.deleteTag(sel) + case "f", "F": + if tags { + return m, nil + } + sel, ok := m.refs.Remotes.Selected() + if !ok { + return m, nil + } + go m.fetchRemote(sel.Name, key == "F") + return m, m.pushToast(fmt.Sprintf("Fetching %s…", sel.Name), components.ToneInfo) + case "r": + m.refs.Loaded = false + go m.fetchRefs() + return m, nil + default: + if tags { + m.refs.Tags.Nav(key) + } else { + m.refs.Remotes.Nav(key) + } + } + return m, nil +} + +func (m *Model) createTag(name string) tea.Cmd { + ctx, cancel := context.WithTimeout(context.Background(), 15e9) + defer cancel() + if err := m.git.TagCreate(ctx, name, "", ""); err != nil { + return m.pushToast(fmt.Sprintf("tag failed: %v", err), components.ToneError) + } + go m.fetchRefs() + return m.pushToast(fmt.Sprintf("Tag %s created", name), components.ToneSuccess) +} + +func (m *Model) deleteTag(name string) tea.Cmd { + ctx, cancel := context.WithTimeout(context.Background(), 15e9) + defer cancel() + if err := m.git.TagDelete(ctx, name); err != nil { + return m.pushToast(fmt.Sprintf("delete failed: %v", err), components.ToneError) + } + go m.fetchRefs() + return m.pushToast(fmt.Sprintf("Tag %s deleted", name), components.ToneSuccess) +} + +func (m *Model) fetchRemote(name string, prune bool) { + ctx, cancel := context.WithTimeout(context.Background(), 120e9) + defer cancel() + if err := m.git.Fetch(ctx, name, prune); err != nil { + m.msgs <- teaMsg{view: -100, data: toastEvent{ + text: fmt.Sprintf("fetch %s failed: %v", name, err), tone: components.ToneError, + }} + return + } + m.msgs <- teaMsg{view: -100, data: toastEvent{ + text: fmt.Sprintf("Fetched %s", name), tone: components.ToneSuccess, + }} +} + +// toastEvent shows a transient notification from background work. +type toastEvent struct { + text string + tone components.Tone +} + // --- Diff & Commit --- func (m *Model) openFileDiff() { @@ -1867,6 +2289,22 @@ type notesEvent struct { err error } +type worktreesEvent struct { + items []*models.Worktree + err error +} + +type stashEvent struct { + items []*models.Stash + err error +} + +type refsEvent struct { + tags []string + remotes []*models.Remote + err error +} + // toastTickMsg fires when the oldest toast may have expired. type toastTickMsg struct{} @@ -1951,6 +2389,14 @@ func (m *Model) handleEngineMsg(msg teaMsg) (tea.Model, tea.Cmd) { return m, nil } + // Background toast (e.g. remote fetch completion). + if msg.view == -100 { + if evt, ok := msg.data.(toastEvent); ok { + return m, m.pushToast(evt.text, evt.tone) + } + return m, nil + } + switch msg.view { case 0: // status if evt, ok := msg.data.(statusEvent); ok { @@ -2024,6 +2470,33 @@ func (m *Model) handleEngineMsg(msg teaMsg) (tea.Model, tea.Cmd) { m.notes.UpdateNotes(evt.items) } } + case 8: // Worktrees + if evt, ok := msg.data.(worktreesEvent); ok { + if evt.err != nil { + m.worktrees.Error = evt.err.Error() + m.worktrees.Loaded = true + } else if evt.items != nil { + m.worktrees.UpdateWorktrees(evt.items) + } + } + case 9: // Stash + if evt, ok := msg.data.(stashEvent); ok { + if evt.err != nil { + m.stash.Error = evt.err.Error() + m.stash.Loaded = true + } else if evt.items != nil { + m.stash.UpdateStash(evt.items) + } + } + case 10: // Refs + if evt, ok := msg.data.(refsEvent); ok { + if evt.err != nil { + m.refs.Error = evt.err.Error() + m.refs.Loaded = true + } else { + m.refs.UpdateRefs(evt.tags, evt.remotes) + } + } } return m, nil } diff --git a/pkg/tui/app_header_test.go b/pkg/tui/app_header_test.go index 4400f68..334b126 100644 --- a/pkg/tui/app_header_test.go +++ b/pkg/tui/app_header_test.go @@ -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") + } +} diff --git a/pkg/tui/app_palette.go b/pkg/tui/app_palette.go index 7032b24..977910e 100644 --- a/pkg/tui/app_palette.go +++ b/pkg/tui/app_palette.go @@ -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": diff --git a/pkg/tui/shell/route.go b/pkg/tui/shell/route.go index 08395ef..0f08ec0 100644 --- a/pkg/tui/shell/route.go +++ b/pkg/tui/shell/route.go @@ -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. @@ -24,7 +27,7 @@ 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"}, @@ -32,6 +35,9 @@ var Tabs = []Tab{ {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"). diff --git a/pkg/tui/shell/shell_test.go b/pkg/tui/shell/shell_test.go index 757c1ff..cb4dcb1 100644 --- a/pkg/tui/shell/shell_test.go +++ b/pkg/tui/shell/shell_test.go @@ -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{} @@ -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") diff --git a/pkg/tui/views/help.go b/pkg/tui/views/help.go index 2bb2e7b..2cb19c8 100644 --- a/pkg/tui/views/help.go +++ b/pkg/tui/views/help.go @@ -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"}, }, }, @@ -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{ diff --git a/pkg/tui/views/newviews_test.go b/pkg/tui/views/newviews_test.go index 71029c0..a8e6fc9 100644 --- a/pkg/tui/views/newviews_test.go +++ b/pkg/tui/views/newviews_test.go @@ -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()) + } +} diff --git a/pkg/tui/views/palette.go b/pkg/tui/views/palette.go index 02f9aa5..9acf509 100644 --- a/pkg/tui/views/palette.go +++ b/pkg/tui/views/palette.go @@ -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"}, diff --git a/pkg/tui/views/prompt.go b/pkg/tui/views/prompt.go new file mode 100644 index 0000000..f86c583 --- /dev/null +++ b/pkg/tui/views/prompt.go @@ -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" +} diff --git a/pkg/tui/views/refs.go b/pkg/tui/views/refs.go new file mode 100644 index 0000000..209424e --- /dev/null +++ b/pkg/tui/views/refs.go @@ -0,0 +1,123 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/zaidejjo/zgit/pkg/core/models" + "github.com/zaidejjo/zgit/pkg/tui/components" + "github.com/zaidejjo/zgit/pkg/tui/styles" +) + +// RefsSection selects the focused half of the Refs screen. +type RefsSection int + +const ( + RefsTags RefsSection = iota + RefsRemotes +) + +// RefsModel holds tags + remotes with a focus toggle (t key). +type RefsModel struct { + Tags components.ZList[string] + Remotes components.ZList[*models.Remote] + Section RefsSection + Loaded bool + Error string + Prompt Prompt +} + +// NewRefsModel creates the refs view model. +func NewRefsModel() RefsModel { + m := RefsModel{} + m.Tags.Height = 20 + m.Tags.Match = func(tag, q string) bool { + return strings.Contains(strings.ToLower(tag), q) + } + m.Tags.Render = tagRow + m.Remotes.Height = 20 + m.Remotes.Match = func(r *models.Remote, q string) bool { + return strings.Contains(strings.ToLower(r.Name+" "+r.URL), q) + } + m.Remotes.Render = remoteRow + return m +} + +// UpdateRefs refreshes both lists. +func (m *RefsModel) UpdateRefs(tags []string, remotes []*models.Remote) { + m.Tags.Items = tags + m.Remotes.Items = remotes + m.Loaded = true + m.Error = "" + m.Tags.ClampCursor() + m.Remotes.ClampCursor() +} + +// Toggle flips the focused section. +func (m *RefsModel) Toggle() { + if m.Section == RefsTags { + m.Section = RefsRemotes + } else { + m.Section = RefsTags + } +} + +// View renders tags then remotes with a focus marker. +func (m *RefsModel) View(width int) string { + if m.Error != "" { + return styles.ErrorStyle.Render("Error: " + m.Error) + } + if !m.Loaded { + return styles.LoadingStyle.Render("Loading tags & remotes...") + } + + var b strings.Builder + b.WriteString(m.sectionTitle("Tags", len(m.Tags.Items), m.Section == RefsTags)) + b.WriteString("\n") + if m.Section == RefsTags { + b.WriteString(m.Tags.FilterBar()) + } + if len(m.Tags.Items) == 0 { + b.WriteString(styles.SubtitleStyle.Render(" No tags — press c to create one")) + b.WriteString("\n") + } else { + b.WriteString(m.Tags.ViewRows(width)) + } + b.WriteString("\n") + b.WriteString(m.sectionTitle("Remotes", len(m.Remotes.Items), m.Section == RefsRemotes)) + b.WriteString("\n") + if m.Section == RefsRemotes { + b.WriteString(m.Remotes.FilterBar()) + } + if len(m.Remotes.Items) == 0 { + b.WriteString(styles.SubtitleStyle.Render(" No remotes configured")) + b.WriteString("\n") + } else { + b.WriteString(m.Remotes.ViewRows(width)) + } + return b.String() +} + +func (m *RefsModel) sectionTitle(name string, n int, focused bool) string { + label := fmt.Sprintf(" %s (%d)", name, n) + if focused { + return styles.SectionTitleStyle.Render(label + " ‹t›") + } + return styles.SubtitleStyle.Render(label) +} + +func tagRow(tag string, width int) string { + return fmt.Sprintf(" %s %s", styles.SubtitleStyle.Render("◆"), truncateStr(tag, width-6)) +} + +func remoteRow(r *models.Remote, width int) string { + line := fmt.Sprintf(" %s %s %s", + styles.StatusStagedStyle.Render("●"), + truncateStr(r.Name, 24), styles.SubtitleStyle.Render(truncateStr(r.URL, 50))) + if lipgloss.Width(line) > width { + line = fmt.Sprintf(" %s %s", + styles.StatusStagedStyle.Render("●"), truncateStr(r.Name, width-6)) + } + return line +} diff --git a/pkg/tui/views/stash.go b/pkg/tui/views/stash.go new file mode 100644 index 0000000..bdd7e70 --- /dev/null +++ b/pkg/tui/views/stash.go @@ -0,0 +1,93 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/zaidejjo/zgit/pkg/core/models" + "github.com/zaidejjo/zgit/pkg/tui/components" + "github.com/zaidejjo/zgit/pkg/tui/styles" +) + +// StashModel holds state for the stash list view. +type StashModel struct { + components.ZList[*models.Stash] + Loaded bool + Error string + Prompt Prompt +} + +// NewStashModel creates the stash view model. +func NewStashModel() StashModel { + m := StashModel{} + m.Height = 20 + m.Match = func(s *models.Stash, q string) bool { + hay := strings.ToLower(strings.Join([]string{ + fmt.Sprintf("%d", s.Index), s.Message, s.Hash, + }, " ")) + return strings.Contains(hay, q) + } + m.Render = stashRow + return m +} + +// UpdateStash refreshes the list. +func (m *StashModel) UpdateStash(items []*models.Stash) { + m.Items = items + m.Loaded = true + m.Error = "" + m.ClampCursor() +} + +// View renders the stash list. +func (m *StashModel) View(width int) string { + if m.Error != "" { + return styles.ErrorStyle.Render("Error: " + m.Error) + } + if !m.Loaded { + return styles.LoadingStyle.Render("Loading stash...") + } + if len(m.Items) == 0 { + return styles.SubtitleStyle.Render("Stash is empty — press s to push one") + } + + var b strings.Builder + title := fmt.Sprintf(" Stash (%d)", len(m.Items)) + if strings.TrimSpace(m.Filter) != "" { + title = fmt.Sprintf(" Stash (%d/%d) filter: %s", len(m.Filtered()), len(m.Items), m.Filter) + } + b.WriteString(styles.SectionTitleStyle.Render(title)) + b.WriteString("\n") + b.WriteString(m.FilterBar()) + if len(m.Filtered()) == 0 && strings.TrimSpace(m.Filter) != "" { + return b.String() + styles.SubtitleStyle.Render("No stashes match filter") + } + return b.String() + m.ViewRows(width) +} + +// stashRow renders one stash row (no cursor — ZList wraps it). +func stashRow(s *models.Stash, width int) string { + hash := s.Hash + if len(hash) > 7 { + hash = hash[:7] + } + when := "" + if !s.Time.IsZero() { + when = formatTime(s.Time) + } + msg := s.Message + if msg == "" { + msg = "(no message)" + } + line := fmt.Sprintf(" %s %s %s %s", + styles.SubtitleStyle.Render(fmt.Sprintf("stash@{%d}", s.Index)), + truncateStr(msg, 44), styles.SubtitleStyle.Render(hash), + styles.SubtitleStyle.Render(when)) + if lipgloss.Width(line) > width { + line = fmt.Sprintf(" %s %s", + styles.SubtitleStyle.Render(fmt.Sprintf("stash@{%d}", s.Index)), + truncateStr(msg, width-14)) + } + return line +} diff --git a/pkg/tui/views/worktrees.go b/pkg/tui/views/worktrees.go new file mode 100644 index 0000000..cd1844c --- /dev/null +++ b/pkg/tui/views/worktrees.go @@ -0,0 +1,97 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/zaidejjo/zgit/pkg/core/models" + "github.com/zaidejjo/zgit/pkg/tui/components" + "github.com/zaidejjo/zgit/pkg/tui/styles" +) + +// WorktreeModel holds state for the worktree list view. +type WorktreeModel struct { + components.ZList[*models.Worktree] + Loaded bool + Error string + Prompt Prompt +} + +// NewWorktreeModel creates the worktree view model. +func NewWorktreeModel() WorktreeModel { + m := WorktreeModel{} + m.Height = 20 + m.Match = func(w *models.Worktree, q string) bool { + hay := strings.ToLower(strings.Join([]string{w.Path, w.Branch, w.Head}, " ")) + return strings.Contains(hay, q) + } + m.Render = worktreeRow + return m +} + +// UpdateWorktrees refreshes the list. +func (m *WorktreeModel) UpdateWorktrees(items []*models.Worktree) { + m.Items = items + m.Loaded = true + m.Error = "" + m.ClampCursor() +} + +// View renders the worktree list. +func (m *WorktreeModel) View(width int) string { + if m.Error != "" { + return styles.ErrorStyle.Render("Error: " + m.Error) + } + if !m.Loaded { + return styles.LoadingStyle.Render("Loading worktrees...") + } + if len(m.Items) == 0 { + return styles.SubtitleStyle.Render("No worktrees — press a to add one") + } + + var b strings.Builder + title := fmt.Sprintf(" Worktrees (%d)", len(m.Items)) + if strings.TrimSpace(m.Filter) != "" { + title = fmt.Sprintf(" Worktrees (%d/%d) filter: %s", len(m.Filtered()), len(m.Items), m.Filter) + } + b.WriteString(styles.SectionTitleStyle.Render(title)) + b.WriteString("\n") + b.WriteString(m.FilterBar()) + if len(m.Filtered()) == 0 && strings.TrimSpace(m.Filter) != "" { + return b.String() + styles.SubtitleStyle.Render("No worktrees match filter") + } + return b.String() + m.ViewRows(width) +} + +// worktreeRow renders one worktree row (no cursor — ZList wraps it). +func worktreeRow(w *models.Worktree, width int) string { + mark := styles.StatusStagedStyle.Render("●") + if w.IsBare { + mark = styles.SubtitleStyle.Render("○") + } + name := w.Path + if w.IsMain { + name += styles.ListItemActiveStyle.Render(" ‹main›") + } + detail := w.Branch + if detail == "" { + if len(w.Head) >= 7 { + detail = "detached " + w.Head[:7] + } else { + detail = "bare" + } + } + if w.Locked { + detail += styles.ErrorStyle.Render(" locked") + } + if w.Prunable { + detail += styles.SubtitleStyle.Render(" prunable") + } + line := fmt.Sprintf(" %s %s %s", mark, truncateStr(name, 44), + styles.SubtitleStyle.Render(truncateStr(detail, 30))) + if lipgloss.Width(line) > width { + line = fmt.Sprintf(" %s %s", mark, truncateStr(name, width-6)) + } + return line +}