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) +}