From 55a6721d3c40d345db1e1256e28622462564541c Mon Sep 17 00:00:00 2001 From: Avinash Joshi Date: Wed, 27 May 2026 22:02:39 -0700 Subject: [PATCH 1/2] fix(ui): ClassifyIdle accepts production "main" status so the v0.21.7.0 idle collapse fires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.21.7.0 redesign added a "+ N idle projects · e expand" roll-up for projects whose only row is a non-running (main). It never fired in production because the classifier filtered on `Status == ""` or `StatusStopped`, but `BuildGlobalRows` stamps every synthetic main row with the literal string "main" (`internal/state/listing.go:268`). Test fixtures used the empty/stopped shapes; production code path never matched. Two changes to `internal/ui/projectlist/projectlist.go`: 1. `ClassifyIdle` filter now accepts `Status == "main"` alongside `""` and `StatusStopped`. The `!Alive` check above it still excludes running mains, so semantics for the original cases are unchanged — this just plugs the production-shape case the tests missed. 2. `emitIdleRollup` writes a leading `\n` (and bumps `lineCount`) before the roll-up text. Before, the line jammed against the previous host's last workspace row with no visual separation. Regression test in `idle_test.go` uses `Status: "main"` (the production shape) to lock the contract `BuildGlobalRows` actually produces — so a future classifier change has to consciously reject all three equivalent dormant shapes. New render test asserts the blank line above the roll-up. Two existing tests (`TestRender_MainStatusText`, `TestRender_MainRowBranchInGray`) gain `m.idleExpanded = {"": true}` since their lone-non-running-main fixtures are now correctly classified idle. Co-Authored-By: Claude Opus 4.7 --- internal/ui/projectlist/idle_test.go | 40 +++++++++++++++++++++ internal/ui/projectlist/projectlist.go | 18 +++++++--- internal/ui/projectlist/projectlist_test.go | 5 +++ 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/internal/ui/projectlist/idle_test.go b/internal/ui/projectlist/idle_test.go index 767b945..f5880d2 100644 --- a/internal/ui/projectlist/idle_test.go +++ b/internal/ui/projectlist/idle_test.go @@ -39,6 +39,19 @@ func TestClassifyIdle(t *testing.T) { wantHidden: []bool{true}, wantIdle: map[string]int{"": 1}, }, + { + // Regression: state.BuildGlobalRows stamps Status:"main" on + // every synthetic main row (listing.go:268). Pre-fix, the + // classifier's filter rejected anything that wasn't ""/stopped, + // so production rows never matched and the collapse feature + // was inert. This case locks the production row shape. + name: "lone main row, status=\"main\" (production shape) → idle and hidden", + rows: []state.GlobalRow{ + {Project: "chrome-tab-close-guard", Name: "(main)", IsMain: true, Status: "main"}, + }, + wantHidden: []bool{true}, + wantIdle: map[string]int{"": 1}, + }, { name: "main row with workspaces → not idle", rows: []state.GlobalRow{ @@ -140,6 +153,33 @@ func TestRender_IdleRollupCollapsedByDefault(t *testing.T) { } } +// TestRender_IdleRollupHasBlankLineAbove: the roll-up line must have a +// blank line above it so it doesn't visually glue to the last project's +// last workspace row. Regression for the "too close" complaint when the +// fix that finally classified production main rows as idle landed. +func TestRender_IdleRollupHasBlankLineAbove(t *testing.T) { + m := New(Options{}) + m.SetRows([]state.GlobalRow{ + {Project: "cravd", Name: "fair-comet", Status: state.StatusReady, Alive: true}, + {Project: "brain", Name: "(main)", IsMain: true, Status: "main"}, + }) + out := stripStyle(m.View()) + lines := strings.Split(out, "\n") + var rollupIdx = -1 + for i, ln := range lines { + if strings.Contains(ln, "idle project") { + rollupIdx = i + break + } + } + if rollupIdx <= 0 { + t.Fatalf("roll-up line not found or at top; got:\n%s", out) + } + if strings.TrimSpace(lines[rollupIdx-1]) != "" { + t.Errorf("expected blank line immediately above roll-up; got %q (full:\n%s)", lines[rollupIdx-1], out) + } +} + // TestRender_IdleRollupExpandsOnE: pressing `e` flips the cursor host's // idle state from collapsed to expanded, surfacing the previously-hidden // rows. The roll-up line stays but flips its hint to "e collapse" so diff --git a/internal/ui/projectlist/projectlist.go b/internal/ui/projectlist/projectlist.go index bc46963..b79ddff 100644 --- a/internal/ui/projectlist/projectlist.go +++ b/internal/ui/projectlist/projectlist.go @@ -468,11 +468,13 @@ func ClassifyIdle(rows []state.GlobalRow, idleExpanded map[string]bool) (hidden if r.Alive { continue } - // Only the dormant statuses qualify. Empty status (zero value - // before reconcile) and StatusStopped both mean "nothing's - // running here." Broken/orphaned/setting_up are attention - // states — keep them visible. - if r.Status != "" && r.Status != state.StatusStopped { + // Only the dormant statuses qualify. Empty status, StatusStopped, + // and the literal "main" string (stamped by BuildGlobalRows on + // every synthetic main row) all mean "nothing's running here" — + // the !Alive check above already excluded running mains. + // Broken/orphaned/setting_up are attention states — keep them + // visible. + if r.Status != "" && r.Status != state.StatusStopped && r.Status != "main" { continue } idleByHost[r.Host]++ @@ -715,6 +717,12 @@ func (m Model) renderTable() (string, int) { if m.idleExpanded[host] { hint = "e collapse" } + // Blank line above the roll-up so it doesn't glue to the last + // project's last workspace row. Cheap visual breath; the host + // transition / end-of-loop separators around this call still + // fire, giving the roll-up its own band. + b.WriteString("\n") + lineCount++ line := subtleHelper().Render( fmt.Sprintf(" + %d idle %s · %s", n, noun, hint), ) diff --git a/internal/ui/projectlist/projectlist_test.go b/internal/ui/projectlist/projectlist_test.go index b397708..72fabe8 100644 --- a/internal/ui/projectlist/projectlist_test.go +++ b/internal/ui/projectlist/projectlist_test.go @@ -856,6 +856,9 @@ func TestRender_MainStatusText(t *testing.T) { m.SetRows([]state.GlobalRow{ {Project: "p", IsMain: true, Name: "(main)", Branch: "main", Status: "main", Alive: tc.alive}, }) + // Lone non-running main rows are now classified idle and collapsed + // by default; expand the local host so the row's status text renders. + m.idleExpanded = map[string]bool{"": true} m.cursor = -1 out := m.View() if !strings.Contains(out, tc.want) { @@ -924,6 +927,8 @@ func TestRender_MainRowBranchInGray(t *testing.T) { Status: "main", }, }) + // Lone non-running main rows are idle by default — expand to render. + m.idleExpanded = map[string]bool{"": true} m.cursor = -1 // off-row so the non-selected styling path fires out := m.View() From 57371775a0f49213c16fad7f67d51c9682c5e4ec Mon Sep 17 00:00:00 2001 From: Avinash Joshi Date: Wed, 27 May 2026 22:02:43 -0700 Subject: [PATCH 2/2] chore: bump version and changelog (v0.21.11.0) Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 22 ++++++++++++++++++++++ VERSION | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5abb188..c2aa928 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to canopy are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and canopy adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.21.11.0] - 2026-05-27 — Idle-collapse roll-up actually fires now (the v0.21.7.0 feature was inert in production) + +v0.21.7.0 shipped a "+ N idle projects · e expand" collapse for projects whose only row was a non-running `(main)` — the whole point of the redesign. It never fired. Open the workspaces tab against a host with a dozen registered-but-untouched projects and every single `(main) not started` row was still on screen, exactly as before. + +`ClassifyIdle` in `internal/ui/projectlist/projectlist.go` filtered eligible rows on `Status == ""` or `Status == StatusStopped`. The unit tests passed because the fixtures used those exact shapes. Production main rows don't: `state.BuildGlobalRows` (`internal/state/listing.go:268`) stamps every synthetic main row with the literal string `"main"` — neither empty nor `stopped`. The classifier bailed on every real row; `idleByHost` was always empty; nothing ever got hidden. Textbook test-fixture-doesn't-match-production gap. The fix extends the filter to accept `Status == "main"` alongside the previous two values (the `!Alive` check above it still gates running mains, so semantics for the original cases are unchanged). + +Polish landed in the same ship: the roll-up line was rendering directly under the previous host's last workspace row with no separation. Added a leading blank line inside `emitIdleRollup` so it gets its own band between the project list and the host transition / end-of-listing. + +### Fixed + +- **`ClassifyIdle` now classifies the row shape `BuildGlobalRows` actually produces.** The filter that decides "is this main row dormant?" accepts `Status == "main"` in addition to `""` and `StatusStopped`. `BuildGlobalRows` is the single producer of `GlobalRow` values across both CLI (`canopy ls --all`) and TUI, and it stamps `Status: "main"` on every synthetic main row regardless of liveness; the `r.Alive` check earlier in the classifier already excludes running mains, so the new branch only catches the not-running case the test fixtures were modeling all along. Net effect: a host with N untouched projects now collapses to one `+ N idle projects · e expand` line; pressing `e` flips it open with `e collapse`. The original v0.21.7.0 j/k/g/G nav and `e` toggle all work — they were always wired correctly, the data just never reached them. + +- **Idle roll-up renders with a blank line above it.** `emitIdleRollup` writes a leading `\n` (and bumps `lineCount` to keep cursor visibility honest) before the `+ N idle …` text. In the end-of-host path it separates the roll-up from the last workspace row; in the host-transition path it pairs with the existing trailing separator so the roll-up sits in its own band. No conditional — the early-return on `n == 0` already prevents emitting the spacer when there's nothing to roll up. + +### Tests + +- **`internal/ui/projectlist/idle_test.go` adds the production row shape to the `ClassifyIdle` table.** New case `lone main row, status="main" (production shape) → idle and hidden` mirrors what `BuildGlobalRows` stamps; the case lives next to the existing empty/stopped cases so a future change has to consciously reject all three equivalent dormant shapes. Locks the production-row contract the original tests never exercised. + +- **`TestRender_IdleRollupHasBlankLineAbove` regression-guards the spacer.** Builds a model with one alive workspace row + one production-shape main row, splits `View()` on `\n`, finds the index of the roll-up line, and asserts the line immediately above it is whitespace-only. Pure rendered-output check — no style-stripping needed because the assertion is structural. + +- **Two existing `projectlist_test.go` tests opt into expanded mode.** `TestRender_MainStatusText` and `TestRender_MainRowBranchInGray` set up exactly the lone-non-running-main shape that the fix now correctly classifies idle, so they would render an empty band without an expansion override. Each gains a single `m.idleExpanded = map[string]bool{"": true}` line before `View()` so the row still appears for the assertion. Their original intent (status-text rendering, branch-icon rendering for the main row) is preserved — they're just no longer accidentally riding the misclassification. + ## [0.21.10.0] - 2026-05-28 — New-workspace picker on remote rows: PR / Issue / Branch reach parity with local Press `n` on a remote row and the picker now offers all five start variants — Fresh, Prompt, PR, Issue, Branch — same as local. Before this, only Fresh and Prompt were wired through `canopy new --on `; the picker hid the other three because the loaders (`gh pr list`, `gh issue list`, `git for-each-ref`) ran with `cmd.Dir = projectRoot`, and a remote-row target has no local project root — its repo lives on tower. The fix lets the picker SSH `gh` and `git` against the project cwd *on the remote host*, then forwards the chosen `--pr` / `--issue` / `--branch` flag through the existing `remoteCreateCmd` dispatch. diff --git a/VERSION b/VERSION index c7a2e05..a6b1315 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.21.10.0 +0.21.11.0