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
9 changes: 7 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ graph TD
| `internal/proc` | `DetachTTY` — run probes without a controlling terminal; `KillGroup` — process-group SIGKILL (plain `Kill` on Windows) |
| `internal/ui` | Lip Gloss styles, `PlaceOverlay`, `StripANSI` |
| `internal/updater` | Detect the package manager that owns an installed binary and produce an update `Plan{Manager, Argv, Display}` |
| `internal/version` | Detect the installed version locally — `InstalledVersion(t) (ver, present)`; GitHub API with a 24-hour cache; semver comparison (`IsNewer`); keepkit's own release check (`SelfRepo`, `SelfLatest`) |
| `internal/version` | Detect the installed version locally — `InstalledVersion(t) (ver, present)`; GitHub API with a 24-hour cache; semver comparison (`IsNewer`) and the card's version spelling (`DisplayVersion`); keepkit's own release check (`SelfRepo`, `SelfLatest`) |

`configdir`, `launcher`, `logx`, `proc`, `ui`, `updater` and `version` sit at the bottom of the import graph:
they know nothing about the TUI (`ui`, `updater` and `version` reach only into
Expand Down Expand Up @@ -169,9 +169,14 @@ Key invariants:
name before the change and remap the index afterwards (`indexOfMeta`).
- **Search is a transaction.** `/` remembers `searchPrevName`; `enter` commits the
selection (focus moves to the card), `esc` rolls the cursor back to the previous tool.
- **The card has one value column.** Every `label: value` line in `[info]` and `[notes]`
pads its label through `cardLabel` to `cardLabelWidth` (the widest label the card can
print), the single definition of that column — `ui.MetaDetailLabelStyle` carries no
`Width`, or the two sections would drift apart again. Wrapped values (`note`, `tags`)
wrap to `inner - cardLabelWidth` and hang under the column via `hangIndent`.
- **Card links are indexed, not parsed.** `buildCard()` returns the card text plus a
`line → URL` map recorded while writing (line heights vary with wrapping), so a
click on the `repo:` line or the changelog release URL opens the browser. `handleMouse`
click on the `repo:` line (shown as the bare `owner/repo`, linked as the full ref) or the changelog release URL opens the browser. `handleMouse`
rebuilds the map per click, which is why it can never describe stale content.
- **A click's X picks the panel, `panelRow` decides whether it is on one at all.**
The outer margin, the borders and the status bars share the panels' columns; with a
Expand Down
7 changes: 4 additions & 3 deletions CLAUDE.md

Large diffs are not rendered by default.

47 changes: 42 additions & 5 deletions internal/model/cardlinks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

const (
linkRepo = "github.com/cli/cli"
linkShort = "cli/cli"
linkRelURL = "https://github.com/cli/cli/releases/tag/v2.0.0"
)

Expand Down Expand Up @@ -48,15 +49,15 @@ func TestBuildCardLinks(t *testing.T) {
{
name: "repo line only while the changelog loads",
setup: func(m *Model) { m.changelogLoadingFor = "gh" },
want: map[string]string{"https://" + linkRepo: "repo: " + linkRepo},
want: map[string]string{"https://" + linkRepo: cardLabel("repo:") + linkShort},
},
{
name: "changelog url linked alongside the repo",
setup: func(m *Model) {
m.changelogData["gh"] = changelogMsg{toolName: "gh", htmlUrl: linkRelURL, body: "release notes"}
},
want: map[string]string{
"https://" + linkRepo: "repo: " + linkRepo,
"https://" + linkRepo: cardLabel("repo:") + linkShort,
linkRelURL: linkRelURL,
},
},
Expand All @@ -65,14 +66,14 @@ func TestBuildCardLinks(t *testing.T) {
setup: func(m *Model) {
m.changelogData["gh"] = changelogMsg{toolName: "gh", htmlUrl: linkRelURL, err: errors.New("boom")}
},
want: map[string]string{"https://" + linkRepo: "repo: " + linkRepo},
want: map[string]string{"https://" + linkRepo: cardLabel("repo:") + linkShort},
},
{
name: "release without an html url",
setup: func(m *Model) {
m.changelogData["gh"] = changelogMsg{toolName: "gh", body: "release notes"}
},
want: map[string]string{"https://" + linkRepo: "repo: " + linkRepo},
want: map[string]string{"https://" + linkRepo: cardLabel("repo:") + linkShort},
},
{
name: "update marker and wrapped about shift both lines",
Expand All @@ -85,7 +86,7 @@ func TestBuildCardLinks(t *testing.T) {
m.changelogData["gh"] = changelogMsg{toolName: "gh", htmlUrl: linkRelURL, body: "release notes"}
},
want: map[string]string{
"https://" + linkRepo: "repo: " + linkRepo,
"https://" + linkRepo: cardLabel("repo:") + linkShort,
linkRelURL: linkRelURL,
},
},
Expand Down Expand Up @@ -136,6 +137,42 @@ func TestBuildCardLinks(t *testing.T) {
}
})

// The repo line shows the bare owner/repo but links the full ref, so the
// two must not be assumed equal — and a ref NormalizeRepo rejects (an
// unsupported or spoofed host) must render in full, or the display would
// hide the host that makes the link not what it looks like.
t.Run("shortened display, full link", func(t *testing.T) {
m := linkedCardModel(t, linkRepo)
content, links := m.buildCard()
for line, url := range links {
if url != "https://"+linkRepo {
continue
}
got := cardLine(content, line)
if !strings.Contains(got, cardLabel("repo:")+linkShort) {
t.Errorf("repo line = %q, want the bare %q", got, linkShort)
}
if strings.Contains(got, "github.com") {
t.Errorf("repo line = %q, want the host dropped from the display", got)
}
}

spoofed := "github.com.evil.com/cli/cli"
m = linkedCardModel(t, spoofed)
content, links = m.buildCard()
if len(links) != 1 {
t.Fatalf("links = %v, want the repo line linked", links)
}
for line, url := range links {
if url != "https://"+spoofed {
t.Errorf("link = %q, want the raw ref %q", url, "https://"+spoofed)
}
if got := cardLine(content, line); !strings.Contains(got, spoofed) {
t.Errorf("repo line = %q, want the unshortenable ref shown in full", got)
}
}
})

t.Run("no repo, no links", func(t *testing.T) {
m := linkedCardModel(t, "")
if _, links := m.buildCard(); len(links) != 0 {
Expand Down
85 changes: 66 additions & 19 deletions internal/model/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -1138,6 +1138,32 @@ func scrollColumn(vp viewport.Model, focused bool) string {
return strings.Join(rows, "\n")
}

// cardLabelWidth is the card's value column: every "label: value" line in the
// card — [info] and [notes] alike — pads its label to it, so the values line
// up in one column instead of each label pushing its own value to a different
// indent. It is the widest label buildCard can print ("maintenance:", 12) plus
// the separating space, so no line ever needs more room than the column gives.
// A label longer than this would still render (cardLabel never truncates), it
// would just push its own value one column past the rest.
const cardLabelWidth = len("maintenance:") + 1

// cardLabel pads a card label to cardLabelWidth, separator included — it is
// the single definition of the value column, so a label added to either
// section lands in it for free (ui.MetaDetailLabelStyle deliberately carries
// no Width of its own anymore, or the two would drift).
func cardLabel(label string) string {
return label + strings.Repeat(" ", max(cardLabelWidth-utf8.RuneCountInString(label), 1))
}

// hangIndent pushes every line after the first to the value column, so a
// wrapped note or tag reads as one block under its label instead of falling
// back to column 0. Applied to already-styled text: each rendered line carries
// its own escape sequences, so inserting plain spaces between them is safe and
// keeps the indent itself unstyled.
func hangIndent(s string) string {
return strings.ReplaceAll(s, "\n", "\n"+strings.Repeat(" ", cardLabelWidth))
}

// renderCard is the card text alone — the shape ~30 SetContent call sites want.
// The clickable-link index is built by buildCard and only handleMouse needs it.
func (m Model) renderCard() string {
Expand Down Expand Up @@ -1210,15 +1236,27 @@ func (m Model) buildCard() (string, map[int]string) {
if hasInfo {
sb.WriteString(m.sectionDivider("info"))
if t.GitHub != "" {
// Clickable: resolves exactly like the [o] key does.
// The value shows the bare owner/repo: the host is implied, and its
// 11 cells are worth more than the label column costs — at the
// 80-column baseline the panel is 30 wide, where the full ref was
// cut mid-name. NormalizeRepo answering "" (an unsupported or
// spoofed host, e.g. "github.com.evil.com/x/y") falls back to the
// raw value on purpose: shortening there would hide the very host
// that makes the link not what it looks like.
repoText := t.GitHub
if short := loader.NormalizeRepo(t.GitHub); short != "" {
repoText = short
}
// Clickable: resolves exactly like the [o] key does — the full
// t.GitHub, never the shortened display text.
links[strings.Count(sb.String(), "\n")] = "https://" + t.GitHub
sb.WriteString(ui.GithubStyle.Render("repo: "+t.GitHub) + "\n")
sb.WriteString(ui.GithubStyle.Render(cardLabel("repo:")+repoText) + "\n")
if !hasCard && m.repoStatus[t.Name] == "rate-limited" {
sb.WriteString(ui.WarnStyle.Render("rate limited — press [L]") + "\n")
}
}
if hasCard && card.Stars > 0 {
sb.WriteString(ui.InfoStyle.Render(fmt.Sprintf("stars: %s", formatStars(card.Stars))) + "\n")
sb.WriteString(ui.InfoStyle.Render(cardLabel("stars:")+formatStars(card.Stars)) + "\n")
}
// The version-less states resolve only once the local probe reported
// back (InstalledKnown) — before that an empty Installed just means the
Expand All @@ -1232,17 +1270,17 @@ func (m Model) buildCard() (string, map[int]string) {
// glyph is invisible in most editors and diffs and gets silently lost.
switch {
case installed != "":
sb.WriteString(ui.InfoStyle.Render("installed: \uf412 "+installed) + "\n")
sb.WriteString(ui.InfoStyle.Render(cardLabel("installed:")+"\uf412 "+version.DisplayVersion(installed)) + "\n")
case vinfo.InstalledKnown && vinfo.InstalledPresent:
sb.WriteString(ui.InfoStyle.Render("installed: ") +
sb.WriteString(ui.InfoStyle.Render(cardLabel("installed:")) +
ui.OkStyle.Render("✓") +
ui.InfoStyle.Render(" no version") + "\n")
case vinfo.InstalledKnown:
sb.WriteString(ui.InfoStyle.Render("installed: ") +
sb.WriteString(ui.InfoStyle.Render(cardLabel("installed:")) +
ui.DangerStyle.Render("✕") +
ui.InfoStyle.Render(" not installed") + "\n")
default:
sb.WriteString(ui.InfoStyle.Render("installed: detecting…") + "\n")
sb.WriteString(ui.InfoStyle.Render(cardLabel("installed:")+"detecting…") + "\n")
}
if hasCard {
if card.Latest != "" {
Expand All @@ -1255,20 +1293,20 @@ func (m Model) buildCard() (string, map[int]string) {
suffix = " (" + date + ")"
}
if m.hasUpdate(t.Name) {
sb.WriteString(ui.InfoStyle.Render("latest: ") +
ui.UpdateAvailableStyle.Render(" "+card.Latest+" ↑") +
sb.WriteString(ui.InfoStyle.Render(cardLabel("latest:")) +
ui.UpdateAvailableStyle.Render("\uf412 "+version.DisplayVersion(card.Latest)+" ↑") +
ui.InfoStyle.Render(suffix) + "\n")
} else {
sb.WriteString(ui.InfoStyle.Render("latest:  "+card.Latest+suffix) + "\n")
sb.WriteString(ui.InfoStyle.Render(cardLabel("latest:")+"\uf412 "+version.DisplayVersion(card.Latest)+suffix) + "\n")
}
}
if len(card.Languages) > 0 {
label := "languages: "
label := cardLabel("languages:")
bar := renderLangBar(card.Languages, inner, utf8.RuneCountInString(label))
sb.WriteString(ui.InfoStyle.Render(label) + bar + "\n")
}
if card.RepoStatus != "" {
sb.WriteString(ui.InfoStyle.Render("maintenance:") + " " + renderRepoStatus(card.RepoStatus) + "\n")
sb.WriteString(ui.InfoStyle.Render(cardLabel("maintenance:")) + renderRepoStatus(card.RepoStatus) + "\n")
}
}
}
Expand All @@ -1278,28 +1316,37 @@ func (m Model) buildCard() (string, map[int]string) {
sb.WriteString(m.sectionDivider("notes"))
sym := loader.StatusSymbol[mt.Status]
symStyled := ui.StatusStyle(mt.Status).Render(sym + " " + string(mt.Status))
sb.WriteString(ui.MetaDetailLabelStyle.Render("status:") + " " + symStyled + "\n")
sb.WriteString(ui.MetaDetailLabelStyle.Render(cardLabel("status:")) + symStyled + "\n")

// Wrapped values are cut to what is left of the panel once the label
// column is spent — wrapping to the full inner width let the first line
// run cardLabelWidth cells past the panel edge, where the viewport
// truncates it (it does not soft-wrap) — and their continuation lines
// hang under the value column instead of falling back to column 0.
valueW := max(inner-cardLabelWidth, 10)

if m.mode == modeEditNote {
sb.WriteString(ui.MetaDetailLabelStyle.Render("note:") + " " + m.noteInput.View() + "\n")
sb.WriteString(ui.MetaDetailLabelStyle.Render(cardLabel("note:")) + m.noteInput.View() + "\n")
} else {
noteText := mt.Note
if noteText == "" {
noteText = "— (press e to edit)"
}
wrapped := wrapText(noteText, inner)
sb.WriteString(ui.MetaDetailLabelStyle.Render("note:") + " " + ui.MetaNoteStyle.Render(wrapped) + "\n")
wrapped := wrapText(noteText, valueW)
sb.WriteString(ui.MetaDetailLabelStyle.Render(cardLabel("note:")) +
hangIndent(ui.MetaNoteStyle.Render(wrapped)) + "\n")
}

if m.mode == modeEditTags {
sb.WriteString(ui.MetaDetailLabelStyle.Render("tags:") + " " + m.tagsInput.View() + "\n")
sb.WriteString(ui.MetaDetailLabelStyle.Render(cardLabel("tags:")) + m.tagsInput.View() + "\n")
} else {
tagsText := tagOf(mt)
if tagsText == "" {
tagsText = "— (press t to edit)"
}
wrapped := wrapText(tagsText, inner)
sb.WriteString(ui.MetaDetailLabelStyle.Render("tags:") + " " + ui.MetaTagStyle.Render(wrapped) + "\n")
wrapped := wrapText(tagsText, valueW)
sb.WriteString(ui.MetaDetailLabelStyle.Render(cardLabel("tags:")) +
hangIndent(ui.MetaTagStyle.Render(wrapped)) + "\n")
}
}

Expand Down
Loading
Loading