Public: anyone can read this; the crew can change it.
A repository's branches page speaks the home page's language #9750
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9746-a-repository-s-branches-page-speaks-the → main 3 commits fork 4 threads · 4 unresolved
3 files
changed +61
−70
docs/design/areas/branches.md added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | # Branches | |
| 2 | ||
| 3 | The branches page (`src/web/pages/branches.ts`) is a plain list of rows. The name is the row's one accent link; Pin and New pull are quiet text links in the trail, and `.branch-del` is the Delete link, muted until hover or focus turns it `--danger`. On a phone the trail drops under the row's text and wraps. |
src/web/pages/branches.ts +50 −70
| @@ -33,11 +33,9 @@ import { localScope } from "../scope.ts"; | ||
| 33 | 33 | import { |
| 34 | 34 | badge, |
| 35 | 35 | btn, |
| 36 | card, | |
| 37 | 36 | empty, |
| 38 | 37 | field, |
| 39 | 38 | icon, |
| 40 | listCard, | |
| 41 | 39 | selectField, |
| 42 | 40 | sha, |
| 43 | 41 | time, |
| @@ -210,32 +208,17 @@ function branchRow( | ||
| 210 | 208 | </span> |
| 211 | 209 | <span class="row-trail"> |
| 212 | 210 | ${divergence(row)} |
| 213 | ${when(canWrite, () => | |
| 214 | btn("Pin", { | |
| 215 | href: `${base}/builds/new?sha=${row.oid}&source=${encodeURIComponent(`branch ${row.name}`)}`, | |
| 216 | size: "sm", | |
| 217 | variant: "outline", | |
| 218 | icon: "pin", | |
| 219 | label: `Pin a build of ${row.name}`, | |
| 220 | }), | |
| 211 | ${when( | |
| 212 | canWrite, | |
| 213 | () => html`<a class="small" href="${base}/builds/new?sha=${row.oid}&source=${encodeURIComponent(`branch ${row.name}`)}" aria-label="Pin a build of ${row.name}">Pin</a>`, | |
| 221 | 214 | )} |
| 222 | ${when(!row.isDefault && canWrite, () => | |
| 223 | btn("New pull", { | |
| 224 | href: `${base}/pulls/new?head=${encoded}`, | |
| 225 | size: "sm", | |
| 226 | variant: "outline", | |
| 227 | icon: "pull", | |
| 228 | }), | |
| 215 | ${when( | |
| 216 | !row.isDefault && canWrite, | |
| 217 | () => html`<a class="small" href="${base}/pulls/new?head=${encoded}" aria-label="Open a pull from ${row.name}">New pull</a>`, | |
| 229 | 218 | )} |
| 230 | ${when(!row.isDefault && canWrite, () => | |
| 231 | btn("Delete", { | |
| 232 | href: `/-/confirm?to=${encodeURIComponent( | |
| 233 | `${base}/branches/${encodeURIComponent(row.name)}/delete`, | |
| 234 | )}`, | |
| 235 | size: "sm", | |
| 236 | variant: "danger", | |
| 237 | label: `Delete branch ${row.name}`, | |
| 238 | }), | |
| 219 | ${when( | |
| 220 | !row.isDefault && canWrite, | |
| 221 | () => html`<a class="small branch-del" href="/-/confirm?to=${encodeURIComponent(`${base}/branches/${encodeURIComponent(row.name)}/delete`)}" aria-label="Delete branch ${row.name}">Delete</a>`, | |
| 239 | 222 | )} |
| 240 | 223 | </span> |
| 241 | 224 | </div> |
| @@ -243,32 +226,27 @@ function branchRow( | ||
| 243 | 226 | } |
| 244 | 227 | |
| 245 | 228 | function createForm(scope: RepoScope, refs: string[], from: string): Html { |
| 246 | return card( | |
| 247 | html`<form method="post" action="${scope.base}/branches" class="field-row"> | |
| 248 | ${field({ | |
| 249 | label: "New branch", | |
| 250 | name: "name", | |
| 251 | placeholder: "feature/thing", | |
| 252 | mono: true, | |
| 253 | required: true, | |
| 254 | autocomplete: "off", | |
| 255 | })} | |
| 256 | ${selectField({ | |
| 257 | label: "From", | |
| 258 | name: "from", | |
| 259 | options: refs.map((r) => ({ | |
| 260 | value: r, | |
| 261 | label: r, | |
| 262 | selected: r === from, | |
| 263 | })), | |
| 264 | })} | |
| 265 | ${btn("Create branch", { variant: "primary", icon: "plus" })} | |
| 266 | </form>`, | |
| 267 | { | |
| 268 | head: html`${icon("branch")} | |
| 269 | <h2>Create a branch</h2>`, | |
| 270 | }, | |
| 271 | ); | |
| 229 | return html`<h2 class="mt">Create a branch</h2> | |
| 230 | <form method="post" action="${scope.base}/branches" class="field-row"> | |
| 231 | ${field({ | |
| 232 | label: "New branch", | |
| 233 | name: "name", | |
| 234 | placeholder: "feature/thing", | |
outdated
Delete as ghost looks the same as Pin. Keep a danger cue (muted text with a danger colour on hover/focus) so a thumb does not hit it by mistake. |
||
| 235 | mono: true, | |
| 236 | required: true, | |
| 237 | autocomplete: "off", | |
| 238 | })} | |
| 239 | ${selectField({ | |
| 240 | label: "From", | |
| 241 | name: "from", | |
| 242 | options: refs.map((r) => ({ | |
| 243 | value: r, | |
| 244 | label: r, | |
outdated
Principle 3: a bare h2 with the form right under it is fine, but the create form comes before the list with no spacing class, while the list h2 has |
||
| 245 | selected: r === from, | |
| 246 | })), | |
| 247 | })} | |
| 248 | ${btn("Create branch", { variant: "primary", icon: "plus" })} | |
| 249 | </form>`; | |
| 272 | 250 | } |
| 273 | 251 | |
| 274 | 252 | // GET /:owner/:name/branches |
| @@ -308,30 +286,32 @@ export async function renderBranches( | ||
| 308 | 286 | ${when(scope.caps.write && scope.repo !== undefined, () => |
| 309 | 287 | createForm(scope, all.length === 0 ? [startFrom] : all, startFrom), |
| 310 | 288 | )} |
| 289 | ${rows.length === 0 && scope.kind === "local" | |
| 290 | ? html`<p class="small"><a href="${scope.base}/builds">Builds</a></p>` | |
| 291 | : null} | |
| 311 | 292 | ${rows.length === 0 |
| 312 | 293 | ? empty({ |
| 313 | 294 | icon: "branch", |
| 314 | 295 | title: "No branches yet", |
| 315 | 296 | note: "Push one, or create the first from the form above.", |
| 316 | 297 | }) |
| 317 | : listCard( | |
| 318 | rows.map((row) => branchRow(scope, scope.repo, row)), | |
| 319 | { | |
| 320 | head: html`<h2> | |
| 321 | ${rows.length} branch${rows.length === 1 ? "" : "es"} | |
| 322 | </h2> | |
| 323 | ${when(scope.kind === "local", () => html`<span class="spacer"></span>${btn("Builds", { href: `${scope.base}/builds`, size: "sm", variant: "ghost", icon: "pin" })}`)} | |
| 324 | ${when( | |
| 325 | !compared && scope.kind === "local", | |
| 326 | () => | |
| 327 | html`<span class="spacer"></span | |
| 328 | ><span class="muted small" | |
| 329 | >Too many branches to compare — ahead/behind is | |
| 330 | omitted.</span | |
| 331 | >`, | |
| 332 | )}`, | |
| 333 | }, | |
| 334 | )} | |
| 298 | : html`<h2 class="mt"> | |
| 299 | ${rows.length} branch${rows.length === 1 ? "" : "es"} | |
| 300 | ${when( | |
| 301 | scope.kind === "local", | |
| 302 | () => html`<a class="small" href="${scope.base}/builds">Builds</a>`, | |
| 303 | )} | |
| 304 | </h2> | |
| 305 | ${when( | |
| 306 | !compared && scope.kind === "local", | |
| 307 | () => | |
| 308 | html`<p class="muted small"> | |
| 309 | Too many branches to compare — ahead/behind is omitted. | |
| 310 | </p>`, | |
| 311 | )} | |
| 312 | <ul class="list"> | |
| 313 | ${rows.map((row) => branchRow(scope, scope.repo, row))} | |
| 314 | </ul>`} | |
| 335 | 315 | </div> |
| 336 | 316 | ${when(scope.kind === "local", () => liveMount({ repo: scope.base }))}`; |
| 337 | 317 |
src/web/style/content.ts +8
| @@ -964,6 +964,14 @@ textarea.editor:focus-visible { outline: none; } | ||
| 964 | 964 | |
| 965 | 965 | /* ---- branches ---- */ |
| 966 | 966 | .branch-row .row-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } |
| 967 | .branch-row .row-meta { white-space: normal; overflow-wrap: anywhere; } | |
| 968 | .branch-row .row-trail { flex: 0 1 auto; flex-wrap: wrap; gap: 4px 12px; } | |
| 969 | .branch-row .branch-del { color: var(--muted-fg); } | |
| 970 | .branch-row .branch-del:hover, .branch-row .branch-del:focus-visible { color: var(--danger); } | |
| 971 | @media (max-width: 719px) { | |
| 972 | .branch-row { flex-wrap: wrap; } | |
| 973 | .branch-row .row-trail { flex: 1 1 100%; justify-content: flex-start; } | |
| 974 | } | |
| 967 | 975 | .branch-div { display: inline-flex; gap: 8px; font-size: 12.5px; } |
| 968 | 976 | .branch-div .ahead { color: var(--add-fg); } |
| 969 | 977 | .branch-div .behind { color: var(--del-fg); } |
Principle 7: a row with three always-visible buttons is a toolbar, not a row. Show Pin and New pull as quiet text links, or fold them into the branch's own page, so the name is the one link. Verify this trail at 360px.