From 6a15c57bd1f3eb21b7e1caea150f1e7ac1e6c4a1 Mon Sep 17 00:00:00 2001 From: Thomas Carr <9591402+htcarr3@users.noreply.github.com> Date: Sat, 14 Feb 2026 20:15:00 -0500 Subject: [PATCH] feat(tui): add detailed dirty counts, last commit, divergence, and PR status Replace binary dirty/clean with staged/modified/untracked counts in the dashboard and CLI. Add last commit info, default branch divergence, and GitHub PR status (via gh CLI) to the workspace detail pane and status command. PR queries fan out in parallel with graceful degradation when gh is unavailable. --- cmd/list.go | 4 +- cmd/mcp_tools.go | 41 +++++++--- cmd/status.go | 55 ++++++++++--- internal/gh/gh.go | 55 +++++++++++++ internal/gh/gh_test.go | 15 ++++ internal/git/git.go | 68 +++++++++++++++++ internal/git/git_test.go | 136 +++++++++++++++++++++++++++++++++ internal/tui/messages.go | 33 ++++---- internal/tui/model.go | 48 ++++++++++-- internal/tui/model_test.go | 3 +- internal/tui/view_test.go | 71 +++++++++++++++-- internal/tui/workspace_list.go | 66 +++++++++++++++- 12 files changed, 540 insertions(+), 55 deletions(-) create mode 100644 internal/gh/gh.go create mode 100644 internal/gh/gh_test.go diff --git a/cmd/list.go b/cmd/list.go index 7581563..09217f2 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -85,8 +85,8 @@ func runList(cmd *cobra.Command, args []string) error { continue } if listDirty { - dirty, _ := git.HasUncommittedChanges(ws.Path) - if !dirty { + dc, _ := git.DirtyStatus(ws.Path) + if !dc.Dirty() { continue } } diff --git a/cmd/mcp_tools.go b/cmd/mcp_tools.go index a594f5c..12b570f 100644 --- a/cmd/mcp_tools.go +++ b/cmd/mcp_tools.go @@ -13,6 +13,7 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/protocollar/fr8/internal/config" "github.com/protocollar/fr8/internal/env" + "github.com/protocollar/fr8/internal/gh" "github.com/protocollar/fr8/internal/git" "github.com/protocollar/fr8/internal/registry" "github.com/protocollar/fr8/internal/state" @@ -268,8 +269,8 @@ func handleWorkspaceList(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal continue } if filterDirty { - dirty, _ := git.HasUncommittedChanges(ws.Path) - if !dirty { + dc, _ := git.DirtyStatus(ws.Path) + if !dc.Dirty() { continue } } @@ -314,7 +315,18 @@ func handleWorkspaceStatus(ctx context.Context, req mcp.CallToolRequest) (*mcp.C branch = ws.Branch } - dirty, _ := git.HasUncommittedChanges(ws.Path) + dc, _ := git.DirtyStatus(ws.Path) + lastCommit, _ := git.LastCommit(ws.Path) + var lastCommitPtr *git.CommitInfo + if lastCommit.Subject != "" { + lastCommitPtr = &lastCommit + } + + var pr *gh.PRInfo + if gh.Available() == nil { + pr, _ = gh.PRStatus(ws.Path, branch) + } + running := false if tmux.Available() == nil { sessionName := tmux.SessionName(tmux.RepoName(rootPath), ws.Name) @@ -331,15 +343,20 @@ func handleWorkspaceStatus(ctx context.Context, req mcp.CallToolRequest) (*mcp.C } return mcpResult(workspaceStatusJSON{ - Name: ws.Name, - Path: ws.Path, - Branch: branch, - Port: ws.Port, - PortEnd: ws.Port + 9, - Dirty: dirty, - Running: running, - CreatedAt: ws.CreatedAt, - Env: envMap, + Name: ws.Name, + Path: ws.Path, + Branch: branch, + Port: ws.Port, + PortEnd: ws.Port + 9, + Dirty: dc.Dirty(), + Staged: dc.Staged, + Modified: dc.Modified, + Untracked: dc.Untracked, + Running: running, + CreatedAt: ws.CreatedAt, + Env: envMap, + LastCommit: lastCommitPtr, + PR: pr, }) } diff --git a/cmd/status.go b/cmd/status.go index 93fd446..cca3486 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/protocollar/fr8/internal/env" + "github.com/protocollar/fr8/internal/gh" "github.com/protocollar/fr8/internal/git" "github.com/protocollar/fr8/internal/jsonout" "github.com/protocollar/fr8/internal/tmux" @@ -33,9 +34,14 @@ type workspaceStatusJSON struct { Port int `json:"port"` PortEnd int `json:"port_end"` Dirty bool `json:"dirty"` + Staged int `json:"staged"` + Modified int `json:"modified"` + Untracked int `json:"untracked"` Running bool `json:"running"` CreatedAt time.Time `json:"created_at"` Env map[string]string `json:"env"` + LastCommit *git.CommitInfo `json:"last_commit,omitempty"` + PR *gh.PRInfo `json:"pr,omitempty"` } func (w workspaceStatusJSON) Concise() any { @@ -66,7 +72,17 @@ func runStatus(cmd *cobra.Command, args []string) error { branch = ws.Branch } - dirty, _ := git.HasUncommittedChanges(ws.Path) + dc, _ := git.DirtyStatus(ws.Path) + lastCommit, _ := git.LastCommit(ws.Path) + var lastCommitPtr *git.CommitInfo + if lastCommit.Subject != "" { + lastCommitPtr = &lastCommit + } + + var pr *gh.PRInfo + if gh.Available() == nil { + pr, _ = gh.PRStatus(ws.Path, branch) + } running := false if tmux.Available() == nil { @@ -84,28 +100,43 @@ func runStatus(cmd *cobra.Command, args []string) error { } } return jsonout.Write(workspaceStatusJSON{ - Name: ws.Name, - Path: ws.Path, - Branch: branch, - Port: ws.Port, - PortEnd: ws.Port + 9, - Dirty: dirty, - Running: running, - CreatedAt: ws.CreatedAt, - Env: envMap, + Name: ws.Name, + Path: ws.Path, + Branch: branch, + Port: ws.Port, + PortEnd: ws.Port + 9, + Dirty: dc.Dirty(), + Staged: dc.Staged, + Modified: dc.Modified, + Untracked: dc.Untracked, + Running: running, + CreatedAt: ws.CreatedAt, + Env: envMap, + LastCommit: lastCommitPtr, + PR: pr, }) } fmt.Printf("Workspace: %s\n", ws.Name) fmt.Printf(" Path: %s\n", ws.Path) fmt.Printf(" Branch: %s\n", branch) - if dirty { - fmt.Printf(" Status: dirty (uncommitted changes)\n") + if dc.Dirty() { + fmt.Printf(" Status: dirty (%d staged, %d modified, %d untracked)\n", dc.Staged, dc.Modified, dc.Untracked) } else { fmt.Printf(" Status: clean\n") } fmt.Printf(" Port: %d (range %d-%d)\n", ws.Port, ws.Port, ws.Port+9) fmt.Printf(" Created: %s\n", ws.CreatedAt.Format("2006-01-02 15:04:05")) + if lastCommitPtr != nil { + fmt.Printf(" Last Commit: %s (%s)\n", lastCommit.Subject, lastCommit.Time.Format("2006-01-02 15:04")) + } + if pr != nil { + draft := "" + if pr.IsDraft { + draft = " (draft)" + } + fmt.Printf(" PR: #%d %s%s\n", pr.Number, pr.State, draft) + } fmt.Println() fmt.Printf("Environment:\n") fmt.Printf(" FR8_WORKSPACE_NAME %s\n", ws.Name) diff --git a/internal/gh/gh.go b/internal/gh/gh.go new file mode 100644 index 0000000..08cc9bd --- /dev/null +++ b/internal/gh/gh.go @@ -0,0 +1,55 @@ +package gh + +import ( + "encoding/json" + "os/exec" + "strings" +) + +// PRInfo holds GitHub pull request status. +type PRInfo struct { + Number int `json:"number"` + State string `json:"state"` + IsDraft bool `json:"is_draft"` + ReviewDecision string `json:"review_decision"` + URL string `json:"url"` +} + +// Available returns nil if the gh CLI is installed. +func Available() error { + _, err := exec.LookPath("gh") + return err +} + +// PRStatus returns PR info for the given branch, or nil if no PR exists. +// Returns nil, nil for all non-critical failures (gh missing, not a GitHub repo, +// no PR for the branch). +func PRStatus(dir, branch string) (*PRInfo, error) { + if Available() != nil { + return nil, nil + } + + cmd := exec.Command("gh", "pr", "view", branch, + "--json", "number,state,isDraft,reviewDecision,url", + "-q", ".") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + // gh returns non-zero for "no PR found", not a GitHub repo, etc. + return nil, nil + } + + trimmed := strings.TrimSpace(string(out)) + if trimmed == "" { + return nil, nil + } + + var pr PRInfo + if err := json.Unmarshal([]byte(trimmed), &pr); err != nil { + return nil, nil + } + if pr.Number == 0 { + return nil, nil + } + return &pr, nil +} diff --git a/internal/gh/gh_test.go b/internal/gh/gh_test.go new file mode 100644 index 0000000..a639a29 --- /dev/null +++ b/internal/gh/gh_test.go @@ -0,0 +1,15 @@ +package gh + +import "testing" + +func TestPRStatusGracefulDegradation(t *testing.T) { + // A temp dir with no GitHub remote should return nil, nil + dir := t.TempDir() + pr, err := PRStatus(dir, "main") + if err != nil { + t.Errorf("expected nil error, got %v", err) + } + if pr != nil { + t.Errorf("expected nil PRInfo, got %+v", pr) + } +} diff --git a/internal/git/git.go b/internal/git/git.go index 3197a5b..70190bb 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strconv" "strings" + "time" ) // Worktree represents a git worktree entry. @@ -118,6 +119,73 @@ func HasUncommittedChanges(dir string) (bool, error) { return strings.TrimSpace(out) != "", nil } +// DirtyCount holds counts of staged, modified, and untracked files. +type DirtyCount struct { + Staged int `json:"staged"` + Modified int `json:"modified"` + Untracked int `json:"untracked"` +} + +// Dirty returns true if any files are staged, modified, or untracked. +func (d DirtyCount) Dirty() bool { + return d.Staged > 0 || d.Modified > 0 || d.Untracked > 0 +} + +// DirtyStatus parses git status --porcelain output and returns file counts. +func DirtyStatus(dir string) (DirtyCount, error) { + out, err := run(dir, "status", "--porcelain") + if err != nil { + return DirtyCount{}, fmt.Errorf("git status: %w", err) + } + + var dc DirtyCount + for _, line := range strings.Split(out, "\n") { + if len(line) < 2 { + continue + } + if line[:2] == "??" { + dc.Untracked++ + continue + } + if line[0] != ' ' && line[0] != '?' { + dc.Staged++ + } + if line[1] != ' ' && line[1] != '?' { + dc.Modified++ + } + } + return dc, nil +} + +// CommitInfo holds summary information about a commit. +type CommitInfo struct { + Subject string `json:"subject"` + Time time.Time `json:"time"` +} + +// LastCommit returns the subject and timestamp of the most recent commit. +func LastCommit(dir string) (CommitInfo, error) { + out, err := run(dir, "log", "-1", "--format=%s|||%ct") + if err != nil { + return CommitInfo{}, fmt.Errorf("git log: %w", err) + } + + parts := strings.SplitN(strings.TrimSpace(out), "|||", 2) + if len(parts) != 2 { + return CommitInfo{}, fmt.Errorf("unexpected git log output: %q", out) + } + + ts, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return CommitInfo{}, fmt.Errorf("parsing commit timestamp: %w", err) + } + + return CommitInfo{ + Subject: parts[0], + Time: time.Unix(ts, 0), + }, nil +} + // IsInsideWorkTree returns true if dir is inside a git repository. func IsInsideWorkTree(dir string) bool { _, err := run(dir, "rev-parse", "--is-inside-work-tree") diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 2c81de4..41db0e2 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -5,6 +5,7 @@ import ( "os/exec" "path/filepath" "testing" + "time" ) func TestParsePorcelain(t *testing.T) { @@ -400,6 +401,141 @@ func TestTrackingBranchIntegration(t *testing.T) { } } +func TestDirtyStatusClean(t *testing.T) { + dir := initTestRepo(t) + dc, err := DirtyStatus(dir) + if err != nil { + t.Fatal(err) + } + if dc.Dirty() { + t.Errorf("expected clean, got %+v", dc) + } +} + +func TestDirtyStatusUntracked(t *testing.T) { + dir := initTestRepo(t) + if err := os.WriteFile(filepath.Join(dir, "new.txt"), []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + dc, err := DirtyStatus(dir) + if err != nil { + t.Fatal(err) + } + if dc.Untracked != 1 { + t.Errorf("Untracked = %d, want 1", dc.Untracked) + } + if dc.Staged != 0 || dc.Modified != 0 { + t.Errorf("expected only untracked, got %+v", dc) + } +} + +func TestDirtyStatusStaged(t *testing.T) { + dir := initTestRepo(t) + if err := os.WriteFile(filepath.Join(dir, "staged.txt"), []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + runGit(t, dir, "add", "staged.txt") + dc, err := DirtyStatus(dir) + if err != nil { + t.Fatal(err) + } + if dc.Staged != 1 { + t.Errorf("Staged = %d, want 1", dc.Staged) + } + if dc.Modified != 0 || dc.Untracked != 0 { + t.Errorf("expected only staged, got %+v", dc) + } +} + +func TestDirtyStatusModified(t *testing.T) { + dir := initTestRepo(t) + // Create a tracked file + if err := os.WriteFile(filepath.Join(dir, "tracked.txt"), []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + runGit(t, dir, "add", "tracked.txt") + runGit(t, dir, "commit", "-m", "add tracked") + // Modify the tracked file + if err := os.WriteFile(filepath.Join(dir, "tracked.txt"), []byte("modified"), 0644); err != nil { + t.Fatal(err) + } + dc, err := DirtyStatus(dir) + if err != nil { + t.Fatal(err) + } + if dc.Modified != 1 { + t.Errorf("Modified = %d, want 1", dc.Modified) + } + if dc.Staged != 0 || dc.Untracked != 0 { + t.Errorf("expected only modified, got %+v", dc) + } +} + +func TestDirtyStatusMixed(t *testing.T) { + dir := initTestRepo(t) + // Staged file + if err := os.WriteFile(filepath.Join(dir, "staged.txt"), []byte("s"), 0644); err != nil { + t.Fatal(err) + } + runGit(t, dir, "add", "staged.txt") + + // Modified tracked file + if err := os.WriteFile(filepath.Join(dir, "tracked.txt"), []byte("t"), 0644); err != nil { + t.Fatal(err) + } + runGit(t, dir, "add", "tracked.txt") + runGit(t, dir, "commit", "-m", "add tracked") + if err := os.WriteFile(filepath.Join(dir, "tracked.txt"), []byte("mod"), 0644); err != nil { + t.Fatal(err) + } + + // Untracked file + if err := os.WriteFile(filepath.Join(dir, "untracked.txt"), []byte("u"), 0644); err != nil { + t.Fatal(err) + } + + dc, err := DirtyStatus(dir) + if err != nil { + t.Fatal(err) + } + // Note: staged.txt was committed by the commit above, so it's no longer staged. + // We need a fresh staged file. + if err := os.WriteFile(filepath.Join(dir, "staged2.txt"), []byte("s2"), 0644); err != nil { + t.Fatal(err) + } + runGit(t, dir, "add", "staged2.txt") + + dc, err = DirtyStatus(dir) + if err != nil { + t.Fatal(err) + } + if dc.Staged != 1 { + t.Errorf("Staged = %d, want 1", dc.Staged) + } + if dc.Modified != 1 { + t.Errorf("Modified = %d, want 1", dc.Modified) + } + if dc.Untracked != 1 { + t.Errorf("Untracked = %d, want 1", dc.Untracked) + } +} + +func TestLastCommitIntegration(t *testing.T) { + dir := initTestRepo(t) + // The init commit has subject "init" from initTestRepo + ci, err := LastCommit(dir) + if err != nil { + t.Fatal(err) + } + if ci.Subject != "init" { + t.Errorf("Subject = %q, want %q", ci.Subject, "init") + } + // Commit time should be recent (within last 60 seconds) + if time.Since(ci.Time) > 60*time.Second { + t.Errorf("commit time %v is too old", ci.Time) + } +} + // runGit is a test helper that runs a git command in dir and fails on error. func runGit(t *testing.T, dir string, args ...string) { t.Helper() diff --git a/internal/tui/messages.go b/internal/tui/messages.go index 5207e71..d11876c 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -1,6 +1,8 @@ package tui import ( + "github.com/protocollar/fr8/internal/gh" + "github.com/protocollar/fr8/internal/git" "github.com/protocollar/fr8/internal/opener" "github.com/protocollar/fr8/internal/registry" "github.com/protocollar/fr8/internal/state" @@ -28,14 +30,18 @@ type repoItem struct { // workspaceItem is a workspace with live git status. type workspaceItem struct { - Workspace state.Workspace - Dirty bool - Merged bool - Ahead int - Behind int - PortFree bool // true when nothing is listening on the workspace port - Running bool // true when a tmux session is active for this workspace - StatusErr error + Workspace state.Workspace + DirtyCount git.DirtyCount // staged/modified/untracked counts + Merged bool + Ahead int // ahead of upstream tracking branch + Behind int // behind upstream tracking branch + DefaultAhead int // ahead of default branch + DefaultBehind int // behind default branch + LastCommit *git.CommitInfo // nil if unavailable + PR *gh.PRInfo // nil if no PR / gh unavailable + PortFree bool // true when nothing is listening on the workspace port + Running bool // true when a tmux session is active for this workspace + StatusErr error } // Messages for async operations. @@ -46,11 +52,12 @@ type reposLoadedMsg struct { } type workspacesLoadedMsg struct { - workspaces []workspaceItem - repoName string - rootPath string - commonDir string - err error + workspaces []workspaceItem + repoName string + rootPath string + commonDir string + defaultBranch string + err error } type archiveResultMsg struct { diff --git a/internal/tui/model.go b/internal/tui/model.go index 55b2a51..ee96aad 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -13,6 +13,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/protocollar/fr8/internal/config" "github.com/protocollar/fr8/internal/env" + "github.com/protocollar/fr8/internal/gh" "github.com/protocollar/fr8/internal/git" "github.com/protocollar/fr8/internal/opener" "github.com/protocollar/fr8/internal/port" @@ -32,6 +33,7 @@ type model struct { repoName string // current repo being viewed rootPath string // root worktree path for current repo commonDir string // git common dir for current repo + defaultBranch string // default branch for current repo shellRequest *shellRequestMsg attachRequest *attachRequestMsg openRequest *openRequestMsg @@ -103,6 +105,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.repoName = msg.repoName m.rootPath = msg.rootPath m.commonDir = msg.commonDir + m.defaultBranch = msg.defaultBranch m.cursor = 0 m.view = viewWorkspaceList return m, nil @@ -375,7 +378,7 @@ func (m model) handleWorkspaceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if len(m.workspaces) > 0 { var names []string for _, ws := range m.workspaces { - if ws.Merged && !ws.Dirty { + if ws.Merged && !ws.DirtyCount.Dirty() { names = append(names, ws.Workspace.Name) } } @@ -632,18 +635,29 @@ func loadWorkspacesCmd(repo registry.Repo) tea.Cmd { items[i].Running = tmux.IsRunning(sessionName) } - dirty, err := git.HasUncommittedChanges(ws.Path) + dc, err := git.DirtyStatus(ws.Path) if err != nil { items[i].StatusErr = err continue } - items[i].Dirty = dirty + items[i].DirtyCount = dc + + ci, err := git.LastCommit(ws.Path) + if err == nil { + items[i].LastCommit = &ci + } if defaultBranch != "" { merged, err := git.IsMerged(ws.Path, ws.Branch, defaultBranch) if err == nil { items[i].Merged = merged } + + da, db, err := git.AheadBehind(ws.Path, ws.Branch, defaultBranch) + if err == nil { + items[i].DefaultAhead = da + items[i].DefaultBehind = db + } } tracking, err := git.TrackingBranch(ws.Path, ws.Branch) @@ -656,11 +670,31 @@ func loadWorkspacesCmd(repo registry.Repo) tea.Cmd { } } + // Fan out PR queries in parallel if gh is available. + if gh.Available() == nil { + type prResult struct { + idx int + pr *gh.PRInfo + } + ch := make(chan prResult, len(items)) + for i, item := range items { + go func(idx int, ws state.Workspace) { + pr, _ := gh.PRStatus(ws.Path, ws.Branch) + ch <- prResult{idx: idx, pr: pr} + }(i, item.Workspace) + } + for range items { + res := <-ch + items[res.idx].PR = res.pr + } + } + return workspacesLoadedMsg{ - workspaces: items, - repoName: repo.Name, - rootPath: rootPath, - commonDir: commonDir, + workspaces: items, + repoName: repo.Name, + rootPath: rootPath, + commonDir: commonDir, + defaultBranch: defaultBranch, } } } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 4e2126b..3f84207 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -5,6 +5,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/protocollar/fr8/internal/git" "github.com/protocollar/fr8/internal/opener" "github.com/protocollar/fr8/internal/registry" "github.com/protocollar/fr8/internal/state" @@ -913,7 +914,7 @@ func TestBatchArchiveFiltersToMergedClean(t *testing.T) { m := seedWorkspaceModel() m.workspaces[0].Merged = true // ws-one: merged + clean m.workspaces[1].Merged = true - m.workspaces[1].Dirty = true // ws-two: merged + dirty (excluded) + m.workspaces[1].DirtyCount = git.DirtyCount{Modified: 1} // ws-two: merged + dirty (excluded) // ws-three: not merged (excluded) m = updateModel(m, keyRune('A')) diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index 923c776..ea4b8dc 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -3,8 +3,11 @@ package tui import ( "strings" "testing" + "time" "github.com/charmbracelet/lipgloss" + "github.com/protocollar/fr8/internal/gh" + "github.com/protocollar/fr8/internal/git" ) func TestFormatStatus(t *testing.T) { @@ -20,9 +23,27 @@ func TestFormatStatus(t *testing.T) { contains: []string{"clean"}, }, { - name: "dirty", - item: workspaceItem{Dirty: true}, - contains: []string{"dirty"}, + name: "dirty modified", + item: workspaceItem{DirtyCount: git.DirtyCount{Modified: 1}}, + contains: []string{"1~"}, + excludes: []string{"clean"}, + }, + { + name: "dirty staged", + item: workspaceItem{DirtyCount: git.DirtyCount{Staged: 2}}, + contains: []string{"2\u2191"}, + excludes: []string{"clean"}, + }, + { + name: "dirty untracked", + item: workspaceItem{DirtyCount: git.DirtyCount{Untracked: 3}}, + contains: []string{"3?"}, + excludes: []string{"clean"}, + }, + { + name: "dirty mixed", + item: workspaceItem{DirtyCount: git.DirtyCount{Staged: 2, Modified: 3, Untracked: 1}}, + contains: []string{"2\u2191", "3~", "1?"}, excludes: []string{"clean"}, }, { @@ -51,8 +72,26 @@ func TestFormatStatus(t *testing.T) { }, { name: "dirty and merged", - item: workspaceItem{Dirty: true, Merged: true}, - contains: []string{"dirty", "merged"}, + item: workspaceItem{DirtyCount: git.DirtyCount{Modified: 1}, Merged: true}, + contains: []string{"1~", "merged"}, + excludes: []string{"clean"}, + }, + { + name: "with PR", + item: workspaceItem{PR: &gh.PRInfo{Number: 42, State: "OPEN"}}, + contains: []string{"PR #42"}, + excludes: []string{"clean"}, + }, + { + name: "with draft PR", + item: workspaceItem{PR: &gh.PRInfo{Number: 10, State: "OPEN", IsDraft: true}}, + contains: []string{"PR #10", "draft"}, + excludes: []string{"clean"}, + }, + { + name: "with approved PR", + item: workspaceItem{PR: &gh.PRInfo{Number: 5, State: "OPEN", ReviewDecision: "APPROVED"}}, + contains: []string{"PR #5", "\u2713"}, excludes: []string{"clean"}, }, { @@ -198,6 +237,28 @@ func TestRenderTitledPanelAlignment(t *testing.T) { } } +func TestRelativeTime(t *testing.T) { + tests := []struct { + name string + ago time.Duration + want string + }{ + {"just now", 5 * time.Second, "just now"}, + {"minutes", 5 * time.Minute, "5m ago"}, + {"hours", 3 * time.Hour, "3h ago"}, + {"days", 48 * time.Hour, "2d ago"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := relativeTime(time.Now().Add(-tt.ago)) + if got != tt.want { + t.Errorf("relativeTime() = %q, want %q", got, tt.want) + } + }) + } +} + func TestTruncate(t *testing.T) { tests := []struct { input string diff --git a/internal/tui/workspace_list.go b/internal/tui/workspace_list.go index 023892c..a730c96 100644 --- a/internal/tui/workspace_list.go +++ b/internal/tui/workspace_list.go @@ -3,6 +3,9 @@ package tui import ( "fmt" "strings" + "time" + + "github.com/protocollar/fr8/internal/gh" ) func renderWorkspaceList(m model) string { @@ -82,7 +85,7 @@ func renderWorkspaceList(m model) string { case m.view == viewConfirmArchive && m.archiveIdx < len(m.workspaces): ws := m.workspaces[m.archiveIdx] msg := fmt.Sprintf("Archive %q?", ws.Workspace.Name) - if ws.Dirty { + if ws.DirtyCount.Dirty() { msg += " (has uncommitted changes!)" } var detail strings.Builder @@ -124,6 +127,20 @@ func renderWorkspaceList(m model) string { } detail.WriteString("\n") detail.WriteString(renderDetailRow("Status", formatStatus(item))) + if item.LastCommit != nil { + commitStr := truncate(item.LastCommit.Subject, 40) + " " + dimStyle.Render("("+relativeTime(item.LastCommit.Time)+")") + detail.WriteString("\n") + detail.WriteString(renderDetailRow("Last Commit", commitStr)) + } + if item.DefaultAhead > 0 || item.DefaultBehind > 0 { + divStr := fmt.Sprintf("+%d / -%d from %s", item.DefaultAhead, item.DefaultBehind, m.defaultBranch) + detail.WriteString("\n") + detail.WriteString(renderDetailRow("Divergence", dimStyle.Render(divStr))) + } + if item.PR != nil { + detail.WriteString("\n") + detail.WriteString(renderDetailRow("PR", formatPR(item.PR))) + } b.WriteString(renderTitledPanel("Details", detail.String(), w)) } b.WriteString("\n") @@ -155,8 +172,18 @@ func formatStatus(item workspaceItem) string { var parts []string - if item.Dirty { - parts = append(parts, statusDirtyStyle.Render("● dirty")) + if item.DirtyCount.Dirty() { + var counts []string + if item.DirtyCount.Staged > 0 { + counts = append(counts, fmt.Sprintf("%d↑", item.DirtyCount.Staged)) + } + if item.DirtyCount.Modified > 0 { + counts = append(counts, fmt.Sprintf("%d~", item.DirtyCount.Modified)) + } + if item.DirtyCount.Untracked > 0 { + counts = append(counts, fmt.Sprintf("%d?", item.DirtyCount.Untracked)) + } + parts = append(parts, statusDirtyStyle.Render("● "+strings.Join(counts, " "))) } if item.Merged { parts = append(parts, statusMergedStyle.Render("✓ merged")) @@ -174,6 +201,9 @@ func formatStatus(item workspaceItem) string { } parts = append(parts, dimStyle.Render(ab)) } + if item.PR != nil { + parts = append(parts, formatPR(item.PR)) + } if len(parts) == 0 { return statusCleanStyle.Render("● clean") @@ -187,3 +217,33 @@ func truncate(s string, max int) string { } return s[:max-1] + "…" } + +// relativeTime returns a human-readable relative time string. +func relativeTime(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } +} + +// formatPR renders a PR badge with appropriate styling. +func formatPR(pr *gh.PRInfo) string { + badge := fmt.Sprintf("PR #%d", pr.Number) + if pr.IsDraft { + badge += " draft" + } + switch pr.ReviewDecision { + case "APPROVED": + badge += " ✓" + case "CHANGES_REQUESTED": + badge += " ✗" + } + return statusMergedStyle.Render(badge) +}