diff --git a/CLAUDE.md b/CLAUDE.md index fbbca1e..49677fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ sources, the frame and the `--json` snapshot, `sections.go` the people, bots and closed groups, one record per pull request and the row held under the cursor, `listing_cache.go` the last listing kept on disk for the next start, `table.go` what `prs list` prints, `document.go` each pull -request's markdown document, a section per heading, `review_rounds.go` the CLAUDE cell, +request's markdown document, a section per heading, `review_rounds.go` the round's cell, the Review section and the tmux windows that run a round, `diff_files.go` the document and review threads `d` writes for nvim, and `opened_on.go` the pull request `prs tui --pr` names. `internal/reviewrounds` reads a pull request's Claude review rounds out of its @@ -129,9 +129,17 @@ is the shape of the threads file `d` hands nvim, held the same way. - **A round record's statuses stay apart.** A skipped reviewer was skipped on purpose and reads as skipped. One not run or not posted counts as a gap beside the findings, and a round none ran reads `claude did not run`, never as a clean one. -- **The CLAUDE column draws only while a row fills it**, so a board on repos no round - reviews never has it. `rowColumns` holds every column with the cell it draws, so a +- **`prs list`'s CLAUDE column draws only while a row fills it**, so a list of repos no + round reviews never has it. On the board the round follows the review on a row's second + line. `listColumns` and `boardColumns` hold every column with the cell it draws, so a column's header and its cells cannot drift apart. +- **A pull request is two lines on the board, and a field keeps its line.** The first line + is `boardColumns` and the second is `secondLine`. A new field goes at the end of + `secondLine`'s flow before the bot, or into `boardColumns`. The second line is cut at the + table's edge, so the checkout's length decides what a narrow screen loses. +- **A field the board and `prs list` both show has one cell builder**, such as `numberCell` + or `botCell`, so the two layouts choose only where it sits. `prs list` places them through + `listColumns` and `titleLine`. - **A window marked `runs_review_round` reads running only until a round finishes after prs opened it.** A session that ran a round keeps its window afterwards, so one prs did not open reads running only where no round has finished. tmux is asked every @@ -183,7 +191,14 @@ is the shape of the threads file `d` hands nvim, held the same way. request's markdown until its row, its detail state or the minute moves. - **A row's `ReaderTitle` is its pull request's title**, because the table's enter would otherwise title the Reader with the row's first cells, which here are its state words. -- **A row's `Under` line shows only once `f` turns summaries on.** +- **`Board.Look` is gotermboard's `LookBoard`**: a margin, the columns spread across the + width, rules under the titles and over the keys, the cursor's row bold on the selection, + and no row chevrons. `PanelFrom` is measured inside the margin, so the margin gives way + from `PanelFrom` columns and the panel shows wherever it would with no margin. +- **A column with no title continues the one before it under `Look.Spread`**, so the + diff's three counts take two untitled columns after DIFF and stay together. +- **`f` applies only where a row or group sets `Under`**, and no prs row does, so `f` + stays out of the footer and `?`. - **A group header that is not `Still` takes the cursor**, so `g` lands on the people header and enter there folds the group. The cursor starts on the first row holding cells, of whichever group draws first, so recently closed waits for the first open listing. diff --git a/README.md b/README.md index e2eba83..7943707 100644 --- a/README.md +++ b/README.md @@ -122,23 +122,32 @@ field it filters on drops a repo silently, and every list it prints still looks moved to another group stays under the cursor where it was, in its new state, and one no read lists any more stays there marked `gone`, each until the cursor moves off it. `m` and `x` refuse a `gone` one. +- **Each pull request takes two lines, with a dotted rule between it and the next.** The + first line holds where it stands, its title, its diff, its age and its branch, its + columns spread across the width. The second holds its checks, its checkout, its number + and its review. A glyph marks each field but the title, and a field's glyph is the same + in every state. - **State is a word, never a color alone.** The first column says where a pull request stands: `ready`, `behind`, `conflicts`, `blocked`, `draft` or `unknown` while open, or `mergeable` where it can merge with its checks not passing, and `merged` or `closed` - after. A Bitbucket row reads `open`, because prs reads no merge state from Bitbucket. `checks - passing`, `review required` and `review 2 posted` follow it. Each reads under every - theme and under `NO_COLOR`, and the colors are the terminal's own 16. -- **A Claude review round reads on its row.** Where one reviewed the pull request, a - `CLAUDE` cell after the review gives the newest round. Once its round comment posts, the - cell is its findings by section, as `claude 2 correctness · 1 design`. It adds `1 not - run` or `1 unread` where a reviewer did not run or its review could not be read, and - reads `claude did not run` where none ran. Before the round comment, it reads `claude - running` while the reviews post, and `claude unfinished` once they have stopped for an - hour. It reads `claude reviewed` where the newest review is of a version prs does not - read. The column shows only while a row fills it. + after. A Bitbucket row reads `open`, because prs reads no merge state from Bitbucket. + `checks passing` sits under the state, and `review required` or `review 2 posted` + follows the number. Each reads under every theme and under `NO_COLOR`, and the colors + are the terminal's own 16. +- **A Claude review round reads on its row.** Where one reviewed the pull request, its + second line gives the newest round after the review. Once its round comment posts, that + is its findings by section, as `claude 2 correctness · 1 design`. It adds `1 not run` or + `1 unread` where a reviewer did not run or its review could not be read, and reads + `claude did not run` where none ran. Before the round comment, it reads `claude running` + while the reviews post, and `claude unfinished` once they have stopped for an hour. It + reads `claude reviewed` where the newest review is of a version prs does not read. - **A pull request whose base is another open one's branch names that one**, as `↑ #12` - after its title. A pull request an app opened names the app. -- **The branch and the checkout are under the row**, dim, where `f` shows them. + after its number. A pull request an app opened names the app at the end of its second + line. +- **A checkout reads from `~`**, and a repo not cloned here reads as its owner/name, `not + cloned here` beside it. +- **The board keeps a margin at each side**, which a narrow terminal gives up first. The + margin also gives way to the side panel, so a terminal wide enough for the panel shows it. - **The glyphs are Nerd Font glyphs**, so the board wants a Nerd Font in the terminal. ## Each pull request's document @@ -287,8 +296,9 @@ prs tui --pr https://bitbucket.example.com/projects/FIN/repos/ledger/pull-reques ## Reading it from a program -`prs list` reads each provider once and prints the board's open rows as a table, the -people's then the bots', under a line naming any provider it could not read. It reads no +`prs list` reads each provider once and prints the board's open rows as a table, one line +a pull request, the people's then the bots', under a line naming any provider it could not +read. A `CLAUDE` column shows the newest review round while a row fills it. It reads no closed pull requests. `prs list --json` prints them as a JSON array, one object per pull request, in the shape [`cmd/prs/list.schema.json`](cmd/prs/list.schema.json) gives. Beside the row's columns it carries each pull request's state, its merge state, its diff --git a/cmd/prs/tui.go b/cmd/prs/tui.go index 6a3a096..8cb1ea8 100644 --- a/cmd/prs/tui.go +++ b/cmd/prs/tui.go @@ -40,21 +40,21 @@ show names, in three groups. people holds those a person opened, and bots those opened, each oldest first. recently closed holds those merged or closed within --since, newest first. Each header counts its rows, and enter on it folds them. -Each row gives where it stands in one word: ready, behind, conflicts, blocked, mergeable -with checks not passing, draft or unknown while open, and merged or closed after. Then its -checks and review in words, its age, or how long ago it closed, its diff, its repo and -number, and its title. f shows its branch and checkout under it. A Bitbucket row reads -open, and only one merged here reaches recently closed, because prs reads no merge state -or close time from Bitbucket. - -A pull request a Claude review round reviewed gains a CLAUDE cell after its review, from -the newest round: its findings by section once the round comment posts, as in claude 2 -correctness · 1 design, with any reviewer that did not run counted beside them. Before the -round comment, it reads claude running while the reviews post and claude unfinished once -they have stopped for an hour. It reads claude reviewed where the newest review is of a -version prs does not read. The column shows only while a row fills it. A window action -marked runs_review_round reads running while its window is open and the round has not -finished. +Each pull request takes two lines. The first gives where it stands in one word: ready, +behind, conflicts, blocked, mergeable with checks not passing, draft or unknown while open, +and merged or closed after. Then its title, its diff, its age, or how long ago it closed, +and its branch. The second gives its checks in words, its checkout, its number and its +review, and names the app that opened it. A Bitbucket row reads open, and only one merged +here reaches recently closed, because prs reads no merge state or close time from +Bitbucket. + +A pull request a Claude review round reviewed reads the newest round after its review: +its findings by section once the round comment posts, as in claude 2 correctness · 1 +design, with any reviewer that did not run counted beside them. Before the round comment, +it reads claude running while the reviews post and claude unfinished once they have +stopped for an hour. It reads claude reviewed where the newest review is of a version prs +does not read. A window action marked runs_review_round reads running while its window is +open and the round has not finished. It opens on the rows the last run on the same repos read, kept under $XDG_CACHE_HOME/prs, with their age on the first line, and reads each provider behind them. It reads the open diff --git a/cmd/prs/tui_test.go b/cmd/prs/tui_test.go index f76793c..0ebfc17 100644 --- a/cmd/prs/tui_test.go +++ b/cmd/prs/tui_test.go @@ -307,6 +307,21 @@ func gitHubStandIn(t *testing.T, listing *gitHub) string { return config } +// listSide is the list's half of each line of a frame, left of the side panel's border. +func listSide(frame string) string { + var list strings.Builder + for line := range strings.SplitSeq(frame, "\n") { + left, _, _ := strings.Cut(line, "│") + list.WriteString(left + "\n") + } + return list.String() +} + +// underRow matches a row whose first line holds title and whose second holds below. +func underRow(title, below string) string { + return regexp.QuoteMeta(title) + `[^\n]*\n[^\n]*` + regexp.QuoteMeta(below) +} + func fileExists(path string) bool { _, err := os.Stat(path) return err == nil @@ -393,6 +408,7 @@ window_actions: } } s.env = map[string]string{ + "HOME": dir, "PATH": bin + string(os.PathListSeparator) + os.Getenv("PATH"), "XDG_CONFIG_HOME": filepath.Join(dir, "config"), "TMUX": filepath.Join(dir, "tmux") + ",1,0", @@ -421,7 +437,7 @@ func (s standIn) ran(t *testing.T, lines ...string) bool { func (s standIn) run(t *testing.T, args ...string) (string, string, int) { t.Helper() cmd := exec.Command(tuidrive.Build(t, "."), args...) - cmd.Env = []string{"HOME=" + t.TempDir()} + cmd.Env = []string{} for name, value := range s.env { cmd.Env = append(cmd.Env, name+"="+value) } @@ -587,8 +603,8 @@ func TestTheBoardSortsPeopleAboveBotsAndClosedBelowInWordsShowsTheDocumentBeside } { term.WaitForPattern(pattern) } - text := term.Frame().Text() - order := []string{"#12 Cache the widget lookups", "#13 Count cache hits", "#9 Log the widget misses", "#8 Bump the gadget's linter"} + text := listSide(term.Frame().Text()) + order := []string{"Cache the widget lookups", "Count cache hits", "Log the widget misses", "Bump the gadget's linter"} for i := 1; i < len(order); i++ { if strings.Index(text, order[i-1]) > strings.Index(text, order[i]) { t.Errorf("%q should sit above %q:\n%s", order[i-1], order[i], text) @@ -602,7 +618,8 @@ func TestTheBoardSortsPeopleAboveBotsAndClosedBelowInWordsShowsTheDocumentBeside term.WaitFor("mona approved at 9e8d7c6") beside := false for line := range strings.SplitSeq(term.Frame().Text(), "\n") { - beside = beside || strings.Contains(line, "review ") && strings.Contains(line, "acme/widget #12 · opened by octo") + list, document, found := strings.Cut(line, "│") + beside = beside || found && strings.TrimSpace(list) != "" && strings.Contains(document, "acme/widget #12 · opened by octo") } if !beside { t.Errorf("#12's document should share a line of the screen with a row of the table:\n%s", term.Frame().Text()) @@ -648,9 +665,9 @@ func TestEachRowReadsItsNewestReviewRoundAndItsDocumentHoldsTheRound(t *testing. t.Fatal(err) } term := tuidrive.Start(t, tuidrive.Program{Path: tuidrive.Build(t, "."), Args: []string{"tui"}, Env: s.env, Cols: 300, Rows: 40}) - term.WaitForPattern(`claude 5 correctness · 3 design · 4 prose .*Count cache hits`) - term.WaitForPattern(`claude unfinished .*Tidy the gadget`) - term.WaitForPattern(`claude reviewed .*Cache the widget lookups`) + term.WaitForPattern(underRow("Count cache hits", "claude 5 correctness · 3 design · 4 prose")) + term.WaitForPattern(underRow("Tidy the gadget", "claude unfinished")) + term.WaitForPattern(underRow("Cache the widget lookups", "claude reviewed")) // d hands nvim the whole document, which the stand-in nvim keeps. The people's rows run // ledger #4, widget #12 and gadget #5, then widget #13 under the bots' header. @@ -705,11 +722,11 @@ func TestEachRowReadsItsNewestReviewRoundAndItsDocumentHoldsTheRound(t *testing. // The cursor is on #13, whose round finished before r opened a window for another. term.Press("r") term.WaitFor("opened review widget #13 in a tmux window") - term.WaitForPattern(`claude running .*Count cache hits`) + term.WaitForPattern(underRow("Count cache hits", "claude running")) if err := os.WriteFile(s.windows, nil, 0o644); err != nil { t.Fatal(err) } - term.WaitForPattern(`claude 5 correctness · 3 design · 4 prose .*Count cache hits`) + term.WaitForPattern(underRow("Count cache hits", "claude 5 correctness · 3 design · 4 prose")) term.Press("q") if code := term.WaitExit(); code != 0 { t.Errorf("q exited %d", code) @@ -722,7 +739,7 @@ func TestEachRowReadsItsNewestReviewRoundAndItsDocumentHoldsTheRound(t *testing. func TestTheDiffSectionShowsASmallDiffWholeAndNamesALargeOnesSize(t *testing.T) { t.Parallel() s := newStandIn(t) - term := tuidrive.Start(t, tuidrive.Program{Path: tuidrive.Build(t, "."), Args: []string{"tui"}, Env: s.env, Cols: 340, Rows: 40}) + term := tuidrive.Start(t, tuidrive.Program{Path: tuidrive.Build(t, "."), Args: []string{"tui"}, Env: s.env, Cols: 360, Rows: 40}) term.WaitFor("people · 3") term.WaitFor(" #5") @@ -923,7 +940,7 @@ func TestOneQuestionMergesEveryMarkedGreenPullRequestInTurnEachAfterItsOwnRead(t t.Fatal(err) } term.Press("enter") - term.WaitFor("merged 3 of 4: acme/widget #21, acme/widget #23, acme/widget #25. Left acme/widget #22: GitHub reads its merge state as behind, not ready") + term.WaitForPattern(`merged 3 of 4: acme/widget #21, acme/widget #23, acme/widget #25\. Left acme/widget #22: GitHub reads its merge state as behind,\s+not\s+ready`) recorded, _ := os.ReadFile(s.recorded) var events []string diff --git a/go.mod b/go.mod index 97ed4a8..553f1fb 100644 --- a/go.mod +++ b/go.mod @@ -10,8 +10,8 @@ require ( github.com/cli/go-gh/v2 v2.17.0 github.com/datapointchris/goclikit v0.5.3 github.com/datapointchris/goselfupdate v0.11.2 - github.com/datapointchris/gotermboard v0.30.0 - github.com/datapointchris/gotermstyle v0.4.0 + github.com/datapointchris/gotermboard v0.35.0 + github.com/datapointchris/gotermstyle v0.5.0 github.com/datapointchris/tuidrive v0.1.3 github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index 5a9965b..465abdf 100644 --- a/go.sum +++ b/go.sum @@ -52,10 +52,10 @@ github.com/datapointchris/goclikit v0.5.3 h1:6ec9nuswLXeIqZLa0YVEQeqsCnrjkqF/3gD github.com/datapointchris/goclikit v0.5.3/go.mod h1:hEbplQyvrWq5m5UepXVNKfv8jvvlmQshNMrWdoHBb6Y= github.com/datapointchris/goselfupdate v0.11.2 h1:7aiYmeatQnfLqHiFawNlFHUohxAyOsiKyio8Vosje4I= github.com/datapointchris/goselfupdate v0.11.2/go.mod h1:k3gVv/y3AwoAyGGn5sN/UcAxhANB2W+GdN9rYw/oob4= -github.com/datapointchris/gotermboard v0.30.0 h1:UOeJm18Ma3a9s6crk0eBxePNhAwjybgBLhCP6g1jMkQ= -github.com/datapointchris/gotermboard v0.30.0/go.mod h1:gzSG1LtOC2McInuKV5mSFJOLZ3EnFIY/1chPyivuxe0= -github.com/datapointchris/gotermstyle v0.4.0 h1:kOovTrAVF4oI/P6ptNg5VTuU9woNpPyjrhcKd6ndJsI= -github.com/datapointchris/gotermstyle v0.4.0/go.mod h1:pcTXWC/xls5Riw0rFtTueCQGlnszB3sqlepRItgdgeg= +github.com/datapointchris/gotermboard v0.35.0 h1:FSy9u9Mi30sfjBGmmc7lFDd71SdIBOPt8IGlNtSt9d4= +github.com/datapointchris/gotermboard v0.35.0/go.mod h1:A/9phVumidQPEVvHBLHTIWGgMfwbyDT+1/0iOHtjmao= +github.com/datapointchris/gotermstyle v0.5.0 h1:xdpuNASa4vS0jSMpBDQOO0rRFGY7jaft5H3hTHOrREE= +github.com/datapointchris/gotermstyle v0.5.0/go.mod h1:pcTXWC/xls5Riw0rFtTueCQGlnszB3sqlepRItgdgeg= github.com/datapointchris/tuidrive v0.1.3 h1:Gmu8Lye5+UMMJB30AbMk0EHosWk6AOW+PxgiEWi7dMA= github.com/datapointchris/tuidrive v0.1.3/go.mod h1:gkHhDQs6zOLaRUP9MFKNTh+IkegUq0RedcBeavctBYY= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= diff --git a/internal/board/board.go b/internal/board/board.go index 09b4b9c..747d318 100644 --- a/internal/board/board.go +++ b/internal/board/board.go @@ -71,8 +71,6 @@ type Board struct { // their windows are open, nil until ReadDetails is called inside tmux. reviewActions []config.WindowAction roundWindows *roundWindows - // columns are the columns the frame being built draws. - columns []rowColumn // working is every command a key started that has not ended, oldest first. working []underWay workCount int @@ -377,7 +375,6 @@ func (b *Board) Frame(now time.Time, _ string) gotermboard.Frame { } prs := b.records(now, extra...) parents := stackParents(prs) - b.columns = b.drawnColumns(prs, now) rows := ordered(b.hold(placedBy(prs))) clear(b.shown) clear(b.drawnIn) @@ -395,7 +392,7 @@ func (b *Board) Frame(now time.Time, _ string) gotermboard.Frame { maps.DeleteFunc(b.rounds, func(key string, _ roundsMemo) bool { return !b.listed(key) }) return gotermboard.Frame{ Strip: b.StripAt(now), - Columns: headers(b.columns), + Columns: headers(boardColumns), Groups: groups, StartAt: b.startAt(), } diff --git a/internal/board/rows.go b/internal/board/rows.go index 1c2e270..26e2191 100644 --- a/internal/board/rows.go +++ b/internal/board/rows.go @@ -3,6 +3,7 @@ package board import ( "fmt" "os" + "path/filepath" "slices" "strconv" "strings" @@ -142,9 +143,9 @@ func stateMeaning(pr pullrequests.PullRequest, state rowState) meaning { // words is an enum as words, CHANGES_REQUESTED as "changes requested". func words(enum string) string { return strings.ToLower(strings.ReplaceAll(enum, "_", " ")) } -// rowColumn is one column of the board: its header, and the cell it draws on a pull request's -// row. The last column, the title, wraps under itself and draws no cell of its own. An optional -// column draws only while a row fills it. +// rowColumn is one column of a table of pull requests: its header, and the cell it draws on a +// pull request's line. A column with no cell is the line's last, which the table fills itself. +// An optional column draws only while a row fills it. type rowColumn struct { gotermboard.Column cell func(b *Board, at rowAt) gotermboard.Cell @@ -158,13 +159,26 @@ type rowAt struct { now time.Time } -// rowColumns carry the state, the checks, the review, the newest Claude review round, the age -// and the sizes, then the repo and the number, then the title wrapping under itself. The -// round's column is optional, so a board on repos no round reviews never has it. The three -// diff counts each right-align in a column of their own, so the signs stack. The branch and -// the checkout are read when acting rather than when scanning, so they sit dim under the row, -// where f shows them. -var rowColumns = []rowColumn{ +// boardColumns are a pull request's first line on the board: where it stands, its title, its +// diff, its age and its branch, spread across the width. The diff's three counts each +// right-align on a column of their own, so the signs stack, and the two after the first carry +// no title, so the spread keeps them beside it. Its second line, under the first column, is +// secondLine. +var boardColumns = []rowColumn{ + {Column: gotermboard.Column{Title: "STATUS"}, cell: func(_ *Board, at rowAt) gotermboard.Cell { return stateCell(at.pr, at.state) }}, + {Column: gotermboard.Column{Title: "TITLE"}, cell: func(_ *Board, at rowAt) gotermboard.Cell { return titleCell(at.pr, at.state) }}, + {Column: gotermboard.Column{Title: "DIFF", Right: true}, cell: diffCell(0)}, + {Column: gotermboard.Column{Right: true}, cell: diffCell(1)}, + {Column: gotermboard.Column{Right: true}, cell: diffCell(2)}, + {Column: gotermboard.Column{Title: "AGE", Right: true}, cell: func(_ *Board, at rowAt) gotermboard.Cell { return ageCell(at.pr, at.state, at.now) }}, + {Column: gotermboard.Column{Title: "BRANCH"}, cell: func(_ *Board, at rowAt) gotermboard.Cell { return branchCell(at.pr) }}, +} + +// listColumns are what prs list prints of a pull request on one line: the state, the checks, +// the review, the newest Claude review round, the age and the sizes, then the repo and the +// number, then the title. The round's column is optional, so a list of repos no round reviews +// never has it. +var listColumns = []rowColumn{ {Column: gotermboard.Column{Title: "STATE"}, cell: func(_ *Board, at rowAt) gotermboard.Cell { return stateCell(at.pr, at.state) }}, {Column: gotermboard.Column{Title: "CHECKS"}, cell: func(_ *Board, at rowAt) gotermboard.Cell { return checksCell(at.pr) }}, {Column: gotermboard.Column{Title: "REVIEW"}, cell: func(_ *Board, at rowAt) gotermboard.Cell { return reviewCell(at.pr) }}, @@ -178,15 +192,13 @@ var rowColumns = []rowColumn{ {Column: gotermboard.Column{Right: true}, cell: diffCell(1)}, {Column: gotermboard.Column{Right: true}, cell: diffCell(2)}, {Column: gotermboard.Column{Title: "REPO", Max: repoWidth}, cell: func(_ *Board, at rowAt) gotermboard.Cell { return repoCell(at.pr) }}, - {Column: gotermboard.Column{Title: "#", Right: true}, cell: func(_ *Board, at rowAt) gotermboard.Cell { - return gotermboard.C(fmt.Sprintf("%s #%d", numberGlyph, at.pr.Number), gotermboard.Yellow) - }}, + {Column: gotermboard.Column{Title: "#", Right: true}, cell: func(_ *Board, at rowAt) gotermboard.Cell { return numberCell(at.pr) }}, {Column: gotermboard.Column{Title: "PULL REQUEST"}}, } -// drawnColumns is rowColumns less each optional one no row of prs fills. -func (b *Board) drawnColumns(prs []pullrequests.PullRequest, now time.Time) []rowColumn { - return slices.DeleteFunc(slices.Clone(rowColumns), func(column rowColumn) bool { +// listedColumns is listColumns less each optional one no row of prs fills. +func (b *Board) listedColumns(prs []pullrequests.PullRequest, now time.Time) []rowColumn { + return slices.DeleteFunc(slices.Clone(listColumns), func(column rowColumn) bool { return column.optional && !slices.ContainsFunc(prs, func(pr pullrequests.PullRequest) bool { return column.cell(b, rowAt{pr: pr, state: b.stateOf(pr), now: now}).Text != "" }) @@ -227,19 +239,23 @@ func parentOf(pr pullrequests.PullRequest, parents map[[2]string]int) int { return parent } -func (b *Board) row(pr pullrequests.PullRequest, parents map[[2]string]int, now time.Time) gotermboard.Row { - at := rowAt{pr: pr, state: b.stateOf(pr), now: now} +// cells are the cells columns draw on the pull request's line. +func (b *Board) cells(columns []rowColumn, at rowAt) []gotermboard.Cell { var cells []gotermboard.Cell - for _, column := range b.columns { + for _, column := range columns { if column.cell != nil { cells = append(cells, column.cell(b, at)) } } + return cells +} + +func (b *Board) row(pr pullrequests.PullRequest, parents map[[2]string]int, now time.Time) gotermboard.Row { + at := rowAt{pr: pr, state: b.stateOf(pr), now: now} return gotermboard.Row{ Key: pr.Key(), - Cells: cells, - WrapLast: titleLine(pr, parentOf(pr, parents), at.state), - Under: gotermboard.C(identity(pr), dim), + Cells: b.cells(boardColumns, at), + Second: b.secondLine(at, parentOf(pr, parents)), Document: b.document(pr), ReaderTitle: pr.Title, StartsOpen: b.isOpenedOn(pr), @@ -342,37 +358,124 @@ func repoCell(pr pullrequests.PullRequest) gotermboard.Cell { return gotermboard.C(pathGlyph+" "+pr.Repo, gotermboard.Cyan) } +// titleCell is the title. A draft's and one with a row state are dim, because neither waits +// on a reviewer. +func titleCell(pr pullrequests.PullRequest, state rowState) gotermboard.Cell { + if state != "" || pr.Draft { + return gotermboard.C(pr.Title, dim) + } + return gotermboard.C(pr.Title, "") +} + // titleLine is the title, the open pull request it is stacked on, and the app that opened it. -// A pull request a person opened carries no mark, so the mark sets an app's row apart. A -// draft's title and one with a row state are dim, because neither waits on a reviewer. +// A pull request a person opened carries no mark, so the mark sets an app's row apart. func titleLine(pr pullrequests.PullRequest, parent int, state rowState) gotermboard.Line { - code := "" - if state != "" || pr.Draft { - code = dim + line := gotermboard.Line{titleCell(pr, state)} + if parent != 0 { + line = append(line, titleGap, stackCell(parent)) } - line := gotermboard.Line{gotermboard.C(pr.Title, code)} + if isBot(pr) { + line = append(line, titleGap, botCell(pr)) + } + return line +} + +// titleGap is the space between the title and each mark after it in prs list. +var titleGap = gotermboard.C(" ", "") + +// branchCell is the branch the pull request merges from. +func branchCell(pr pullrequests.PullRequest) gotermboard.Cell { + return gotermboard.C(branchGlyph+" "+orDash(pr.Branch), gotermboard.Blue) +} + +// numberCell is the pull request's number. +func numberCell(pr pullrequests.PullRequest) gotermboard.Cell { + return gotermboard.C(fmt.Sprintf("%s #%d", numberGlyph, pr.Number), gotermboard.Yellow) +} + +// stackCell is the number of the open pull request this one is stacked on. +func stackCell(parent int) gotermboard.Cell { + return gotermboard.C(fmt.Sprintf("%s #%d", stackGlyph, parent), gotermboard.Yellow) +} + +// botCell names the app that opened the pull request. +func botCell(pr pullrequests.PullRequest) gotermboard.Cell { + return gotermboard.C(botGlyph+" bot "+pr.Author, gotermboard.Magenta) +} + +func isBot(pr pullrequests.PullRequest) bool { return pr.Bot != nil && *pr.Bot } + +// fieldGap is the space between two fields of a second line, wider than a field's own glyph +// and words are apart, so each reads as a field. +var fieldGap = gotermboard.C(" ", "") + +// secondLine is a pull request's second line on the board: its checks under where it stands, +// then where it is checked out, its number and the open pull request it is stacked on, its +// review, its newest Claude review round, and the app that opened it. A field a pull request +// does not have takes no room. The line is cut at the table's edge, so a narrow screen loses +// its end first. +func (b *Board) secondLine(at rowAt, parent int) gotermboard.Line { + pr := at.pr + line := gotermboard.Line{checksCell(pr)} + line = append(line, whereCells(pr)...) + line = append(line, fieldGap, numberCell(pr)) if parent != 0 { - line = append(line, gotermboard.C(fmt.Sprintf(" %s #%d", stackGlyph, parent), gotermboard.Yellow)) + line = append(line, gotermboard.C(" ", ""), stackCell(parent)) } - if pr.Bot != nil && *pr.Bot { - line = append(line, gotermboard.C(" "+botGlyph+" bot "+pr.Author, gotermboard.Magenta)) + line = append(line, fieldGap, reviewCell(pr)) + if claude := claudeCell(b.reviewOf(pr, at.now)); claude.Text != "" { + line = append(line, fieldGap, claude) + } + if isBot(pr) { + line = append(line, fieldGap, botCell(pr)) } return line } -// identity is the branch, the base it targets, and the checkout on this disk, written -// ~-rooted as it would be typed. -func identity(pr pullrequests.PullRequest) string { - where := "not cloned here" - if pr.Path != "" { - where = pr.Path - if home, err := os.UserHomeDir(); err == nil { - if rest, ok := strings.CutPrefix(where, home); ok { - where = "~" + rest - } - } +// whereCells are where the pull request is checked out on this disk, or its repo where it is +// not cloned here, which d and the window keys refuse. +func whereCells(pr pullrequests.PullRequest) gotermboard.Line { + if pr.Path == "" { + return gotermboard.Line{gotermboard.C(pathGlyph+" "+pr.Slug, gotermboard.Cyan), gotermboard.C(" not cloned here", dim)} + } + return gotermboard.Line{gotermboard.C(pathGlyph+" "+homeAsTilde(pr.Path), gotermboard.Cyan)} +} + +// homeAsTilde is path for a person to read, with the home directory written ~ where path sits +// inside it. The cut falls at a path segment, so a sibling of home keeps its whole path. +func homeAsTilde(path string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return path + } + if path == home { + return "~" + } + if rest, ok := strings.CutPrefix(path, home+string(filepath.Separator)); ok { + return "~" + string(filepath.Separator) + rest + } + return path +} + +// sideMargin is the columns of margin at each side of the board from marginFrom columns +// wide. A narrower screen gives up a column a side for every two columns it lacks, rounded +// up, and keeps one. +const ( + sideMargin = 12 + marginFrom = 150 +) + +// Look is gotermboard.LookBoard's: a margin around the screen, the columns spread across the +// width, rules under the titles and above the keys, and the cursor's pull request bold on the +// selection. The margin gives way to the side panel, which gotermboard measures inside it, so +// a screen wide enough for the panel keeps it. Every row opens its document, so no row draws +// a chevron. +func (b *Board) Look(width int) gotermboard.Look { + sides := min(sideMargin, max(sideMargin-(marginFrom-width+1)/2, 1)) + if width >= gotermboard.PanelFrom { + sides = min(sides, (width-gotermboard.PanelFrom)/2) } - return fmt.Sprintf("%s %s into %s %s %s", branchGlyph, orDash(pr.Branch), orDash(pr.Base), pathGlyph, where) + return gotermboard.Look{Sides: sides, Ends: 1, Spread: true, Rules: true, BoldCursor: true, NoRowChevrons: true} } func orDash(text string) string { diff --git a/internal/board/rows_test.go b/internal/board/rows_test.go new file mode 100644 index 0000000..2a4db5c --- /dev/null +++ b/internal/board/rows_test.go @@ -0,0 +1,18 @@ +package board + +import ( + "testing" + + "github.com/datapointchris/gotermboard" +) + +// A terminal PanelFrom columns wide or wider shows the side panel beside the list, because +// the margin gives way: the width inside it still reaches PanelFrom. +func TestATerminalWideEnoughForTheSidePanelKeepsItInsideTheMargin(t *testing.T) { + var b Board + for width := gotermboard.PanelFrom; width <= 2*gotermboard.PanelFrom; width++ { + if inside := width - 2*b.Look(width).Sides; inside < gotermboard.PanelFrom { + t.Errorf("at %d columns the margin leaves %d, under PanelFrom's %d", width, inside, gotermboard.PanelFrom) + } + } +} diff --git a/internal/board/sections.go b/internal/board/sections.go index 55edd42..5f2aaf8 100644 --- a/internal/board/sections.go +++ b/internal/board/sections.go @@ -38,7 +38,7 @@ func sectionOf(pr pullrequests.PullRequest) section { if standing, err := pr.Standing(); err == nil && standing != pullrequests.Open { return recentlyClosed } - if pr.Bot != nil && *pr.Bot { + if isBot(pr) { return bots } return people @@ -235,7 +235,10 @@ func (b *Board) groups(rows []placed, parents map[[2]string]int, now time.Time) if s.in == recentlyClosed && b.unread() && !b.failed() { continue } - group := gotermboard.Group{Key: s.key, Title: gotermboard.Line{gotermboard.C(s.title, gotermboard.Bold)}, Counted: true, Gap: true} + group := gotermboard.Group{ + Key: s.key, Title: gotermboard.Line{gotermboard.C(s.title, gotermboard.Bold)}, Counted: true, Gap: true, + BetweenRows: gotermboard.RuleDotted, + } for _, p := range rows { if p.in != s.in { continue diff --git a/internal/board/table.go b/internal/board/table.go index 606cf34..3be0aa0 100644 --- a/internal/board/table.go +++ b/internal/board/table.go @@ -17,8 +17,8 @@ const columnGap = " " func (b *Board) Table(now time.Time) []gotermboard.Line { prs := b.records(now) parents := stackParents(prs) - b.columns = b.drawnColumns(prs, now) - columns := headers(b.columns) + listed := b.listedColumns(prs, now) + columns := headers(listed) lines := []gotermboard.Line{gotermboard.ReadStatus(b.statuses(), now)} if len(prs) == 0 { return append(lines, gotermboard.Line{gotermboard.C(b.emptyReason(), dim)}) @@ -29,8 +29,8 @@ func (b *Board) Table(now time.Time) []gotermboard.Line { } rows := [][]gotermboard.Cell{header} for _, p := range ordered(placedBy(prs)) { - row := b.row(p.pr, parents, now) - rows = append(rows, append(row.Cells, row.WrapLast...)) + at := rowAt{pr: p.pr, state: b.stateOf(p.pr), now: now} + rows = append(rows, append(b.cells(listed, at), titleLine(p.pr, parentOf(p.pr, parents), at.state)...)) } widths := make([]int, len(columns)-1) for _, cells := range rows {