From 3b06523cff250c254af560c8a32c339d760ffc37 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Thu, 13 Aug 2026 08:38:01 +0300 Subject: [PATCH] web: offer the bd command for the issue on screen --- web/beads_test.go | 161 +++++++++++++++++++++++++++++++++++++++ web/templates/beads.html | 33 ++++++++ 2 files changed, 194 insertions(+) diff --git a/web/beads_test.go b/web/beads_test.go index 3a788cf869153bf74d792230541e4e6fcc736a63..7da03803228c00871655319cc74cd0de7c722ac4 100644 --- a/web/beads_test.go +++ b/web/beads_test.go @@ -1,7 +1,9 @@ package web import ( + "html" "net/http" + "net/url" "strings" "testing" @@ -432,6 +434,165 @@ func TestBeadsStreamIssueDetailWins(t *testing.T) { } } +// --- copy-ready bd commands --------------------------------------------------- + +// The commands offered follow the issue's status, and nothing is offered that bd +// would refuse: an open issue can be claimed or closed, an in-progress one closed +// or put back to open, a closed one only reopened. +func TestBeadsDetailCommandsFollowStatus(t *testing.T) { + for _, tc := range []struct { + name string + issue string + want []string + notWant []string + }{ + { + name: "open", + issue: "i-open", + want: []string{"bd update i-open --claim", "bd close i-open"}, + notWant: []string{"bd reopen", "--status=open"}, + }, + { + name: "in progress", + issue: "i-prog", + want: []string{"bd close i-prog", "bd update i-prog --status=open"}, + notWant: []string{"--claim", "bd reopen"}, + }, + { + name: "closed", + issue: "i-done", + want: []string{"bd reopen i-done"}, + notWant: []string{"--claim", "bd close i-done", "--status=open"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t) + h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) + h.browse.sess = beadsFixture() + setViews(t, h, &beadsView{}) + + rec := h.do("GET", "/~alice/db/view/beads?issue="+tc.issue, nil, nil) + if rec.Code != http.StatusOK { + t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, `class="bead-commands"`) { + t.Fatalf("detail pane missing the command block; body=%s", body) + } + for _, want := range tc.want { + if !strings.Contains(body, want) { + t.Errorf("command block missing %q; body=%s", want, body) + } + } + for _, notWant := range tc.notWant { + if strings.Contains(body, notWant) { + t.Errorf("command block offers %q for a %s issue; body=%s", notWant, tc.name, body) + } + } + // One click selects one command, and nothing here needs a script. + if !strings.Contains(body, "user-select: all") { + t.Errorf("command lines are not user-select: all; body=%s", body) + } + if strings.Contains(body, ""'` + h := newHarness(t) + h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) + h.browse.sess = &fakeSession{ + branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, + tables: beadsTables(), + rowsByTable: map[string]*browse.RowPage{ + "issues": {Columns: []string{"id", "title", "status"}, + Rows: [][]string{{id, "Odd id", "open"}}, Total: 1}, + "dependencies": {Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"}, Total: 0}, + }, + } + setViews(t, h, &beadsView{}) + + rec := h.do("GET", "/~alice/db/view/beads?issue="+url.QueryEscape(id), nil, nil) + if rec.Code != http.StatusOK { + t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + want := `bd update i-a&b<x>"' --claim` + if !strings.Contains(body, want) { + t.Errorf("command missing the escaped id %q; body=%s", want, body) + } + if strings.Contains(body, "bd update "+id) { + t.Errorf("the id reached the page unescaped; body=%s", body) + } + // Escaped, the command still says exactly the stored id. + if got := html.UnescapeString(want); got != "bd update "+id+" --claim" { + t.Errorf("escaped command decodes to %q, want the stored id verbatim", got) + } +} + +// The epic pane is the detail pane with a rollup, so it carries the block too. +func TestBeadsEpicCommandsRender(t *testing.T) { + h := newHarness(t) + h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) + h.browse.sess = beadsEpicFixture() + setViews(t, h, &beadsView{}) + + rec := h.do("GET", "/~alice/db/view/beads?issue=i-epic", nil, nil) + if rec.Code != http.StatusOK { + t.Fatalf("epic: got %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, want := range []string{`class="bead-commands"`, "bd update i-epic --claim", "bd close i-epic"} { + if !strings.Contains(body, want) { + t.Errorf("epic pane missing %q; body=%s", want, body) + } + } +} + +// The block belongs to one issue, so the board must not carry it. +func TestBeadsBoardHasNoCommandBlock(t *testing.T) { + h := newHarness(t) + h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) + h.browse.sess = beadsFixture() + setViews(t, h, &beadsView{}) + + rec := h.do("GET", "/~alice/db/view/beads", nil, nil) + if rec.Code != http.StatusOK { + t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, notWant := range []string{`class="bead-commands"`, "bd update ", "bd close ", "bd reopen "} { + if strings.Contains(body, notWant) { + t.Errorf("the board rendered the command block (%q); body=%s", notWant, body) + } + } +} + func TestBeadsEpicViewRender(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) diff --git a/web/templates/beads.html b/web/templates/beads.html index 0c57da5ea640811005b7f0704566c8c7d226b44c..338bedade43acb764520756bf53a51a807af1409 100644 --- a/web/templates/beads.html +++ b/web/templates/beads.html @@ -144,6 +144,21 @@ pre.field-body { white-space: pre-wrap; overflow-wrap: anywhere; background: var(--bd-panel); border: 1px solid var(--bd-border); color: var(--bd-fg); padding: .5rem; margin: .25rem 0 1rem; } +/* copy-ready bd commands: the page shows work and cannot change it — a write + path would mean the SQL engine, a working set, a commit and a push, the + dependency this build is deliberately free of — so it hands over the command + instead. One quiet block in the same flat, square, hairline idiom as the rest, + not a call-to-action panel. `user-select: all` per line makes one click select + one whole command, which is why no JavaScript and no clipboard API is + involved. */ +.bead-detail .bead-commands { + background: var(--bd-panel); border: 1px solid var(--bd-border); + padding: .3rem .5rem; margin: 0 0 1rem; max-width: 34rem; +} +.bead-detail .bead-commands .cmd { + display: block; font-family: monospace; font-size: .78rem; line-height: 1.6; + color: var(--bd-fg); -webkit-user-select: all; user-select: all; +} .bead-deplist { list-style: none; padding-left: 0; margin-bottom: 0; } .bead-deplist li { padding: .2rem 0; border-top: 1px solid var(--bd-border); } .bead-deplist li:first-child { border-top: none; } @@ -215,6 +230,24 @@ pre.field-body { {{if .Assignee}}@{{.Assignee}}{{end}} {{range .Labels}}{{.}}{{end}} + {{/* The command this page cannot run for you. Which ones are offered follows + the issue's status through the lane the detail pane already derived from + it (Rolling = in progress, Past Stand = closed, Lined Up = open), so there + is no second status vocabulary here — and nothing is offered that bd would + refuse. The command names the issue and nothing about paths: this service + does not know where the tracker is checked out, and the database name is + in the breadcrumb if the reader needs to pick a directory. */}} +
+ {{if eq .Lane "Rolling"}} + bd close {{.ID}} + bd update {{.ID}} --status=open + {{else if eq .Lane "Past Stand"}} + bd reopen {{.ID}} + {{else}} + bd update {{.ID}} --claim + bd close {{.ID}} + {{end}} +
{{if .CreatedBy}}{{end}}
Created by{{.CreatedBy}}