Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>`; 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.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.21.10.0
0.21.11.0
40 changes: 40 additions & 0 deletions internal/ui/projectlist/idle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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
Expand Down
18 changes: 13 additions & 5 deletions internal/ui/projectlist/projectlist.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]++
Expand Down Expand Up @@ -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),
)
Expand Down
5 changes: 5 additions & 0 deletions internal/ui/projectlist/projectlist_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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()

Expand Down
Loading