Public: anyone can read this; the crew can change it.
The feed folds a run of identical crew posts into one line that opens, so the timeline reads as people first #12335
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12326-the-feed-folds-a-run-of-identical-crew-p → main 6 commits fork 3 threads · 3 unresolved
12 files
changed +298
−13
docs/design/areas/feed-fold.md added +4
| @@ -0,0 +1,4 @@ | ||
| 1 | # Feed fold | |
| 2 | ||
| 3 | `.feed-fold`: a crew member's burst of runs and pulls as one `<details>` in a feed card; the summary is the line | |
| 4 | ("scout: 5 runs, 4 pulls"), opening it lists the members, each linking to its own page. No script. |
docs/features/feed-fold.md added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | # Feed fold | |
| 2 | ||
| 3 | Three or more runs and pulls from one crew member inside an hour fold into one line, "scout: 5 runs, 4 pulls", so the | |
| 4 | timeline reads as people first. A person's post, a failure, a change asked for and an ask never fold. | |
| 5 | ||
| 6 | **Web:** open `/feed`. The line is a disclosure; press it to list the members, each linking to its own run or pull. | |
| 7 | Each member keeps its own words, including "(told by bob, by scout)": a burst has many tellers, so the told-by | |
| 8 | text lives on the member lines, not on the folded line. Up to 50 members show, then "and N more". | |
| 9 | ||
| 10 | **Terminal:** open the Feed screen (`ctrl-k`, "Feed"). The folded line reads the same; with the cursor on it, | |
| 11 | `enter` lists the members and `esc` closes the list. | |
| 12 | ||
| 13 | Dismissing a folded line holds as the burst grows (its id is its first member's). | |
| 14 | ||
| 15 | Pinned by `test/feed-fold-bursts.test.ts` (the fold), `test/tui-feed-fold.test.ts` (the terminal) and | |
| 16 | `test/told-by.test.ts` (told-by on the lines); design note in `docs/design/areas/feed-fold.md`. |
src/feed/feed.ts +7 −4
| @@ -13,7 +13,7 @@ import { dismissedItems, mutedRepos } from "../core/interactions.ts"; | ||
| 13 | 13 | import { affinityFor } from "./affinity.ts"; |
| 14 | 14 | import { canRead, getRepoById } from "../core/repos.ts"; |
| 15 | 15 | import { deliveredFor, drainQueue, senderWeight } from "../core/shares.ts"; |
| 16 | import { getUserById, type User } from "../core/users.ts"; | |
| 16 | import { getUserById, getUsersByIds, type User } from "../core/users.ts"; | |
| 17 | 17 | import { candidatesFor, quietRunsFor, type Candidate } from "./items.ts"; |
| 18 | 18 | import { writeStamp } from "../core/write_clock.ts"; |
| 19 | 19 | import { Stamped } from "../kernel/stamped.ts"; |
| @@ -209,9 +209,12 @@ export function readFeedPage( | ||
| 209 | 209 | !muted.has(c.repoId)), |
| 210 | 210 | ); |
| 211 | 211 | // After every filter, so a burst folds only what the viewer would see. |
| 212 | const pageable = foldBursts( | |
| 213 | options.withoutAsks === true ? candidates.filter((c) => c.kind !== "ask") : candidates, | |
| 214 | ).filter((c) => !dismissed.has(c.id)); // a folded post is dismissed under its own id | |
| 212 | const visible = options.withoutAsks === true ? candidates.filter((c) => c.kind !== "ask") : candidates; | |
| 213 | const actors = [...new Set(visible.flatMap((c) => (c.actorId === null ? [] : [c.actorId])))]; | |
| 214 | const crew = new Set( | |
| 215 | [...getUsersByIds(db, actors).values()].filter((u) => u.kind === "agent").map((u) => u.id), | |
| 216 | ); | |
| 217 | const pageable = foldBursts(visible, crew).filter((c) => !dismissed.has(c.id)); // a folded post is dismissed under its own id | |
| 215 | 218 | const cursor = options.cursor; |
| 216 | 219 | // Without a cursor only the page and one more (to know there is a next) are placed. |
| 217 | 220 | let ranked = rank( |
src/feed/fold.ts +77 −1
| @@ -41,7 +41,83 @@ export interface Foldable { | ||
| 41 | 41 | * Replaces each burst with one candidate that ranks where its newest member |
| 42 | 42 | * would (its time) and keeps the rest as they were, in order. |
| 43 | 43 | */ |
| 44 | export function foldBursts(candidates: readonly Candidate[]): Candidate[] { | |
| 44 | export function foldBursts( | |
| 45 | candidates: readonly Candidate[], | |
| 46 | crew: ReadonlySet<string> = new Set(), | |
| 47 | ): Candidate[] { | |
| 48 | return foldCrew(foldIssues(candidates), crew); | |
| 49 | } | |
| 50 | ||
| 51 | /** Three or more crew runs and pulls inside an hour of the first fold into one line that opens. */ | |
| 52 | export const CREW_FOLD = { min: 3, windowMs: 60 * 60_000, shown: 50 } as const; | |
| 53 | ||
| 54 | const plural = (n: number, one: string): string => `${n} ${one}${n === 1 ? "" : "s"}`; | |
| 55 | ||
| 56 | /** | |
| 57 | * A crew member's burst of runs and pulls becomes one post, `scout: 5 runs, 4 pulls`, | |
| 58 | * whose lines are the members, each at its own permalink. A person's post, a failure, | |
| 59 | * a change asked for and an ask are never members: they stay in view. | |
| 60 | */ | |
| 61 | export function foldCrew(candidates: readonly Candidate[], crew: ReadonlySet<string>): Candidate[] { | |
| 62 | if (crew.size === 0) return [...candidates]; | |
| 63 | const by = new Map<string, Candidate[]>(); | |
| 64 | for (const c of candidates) { | |
| 65 | if (c.actorId === null || !crew.has(c.actorId) || c.actor === null) continue; | |
| 66 | if ((c.kind !== "run" && c.kind !== "pull") || c.urgency > 0 || c.ask !== undefined) continue; | |
| 67 | const list = by.get(c.actorId); | |
| 68 | if (list) list.push(c); | |
| 69 | else by.set(c.actorId, [c]); | |
| 70 | } | |
| 71 | const folded = new Map<string, Candidate>(); | |
| 72 | const gone = new Set<string>(); | |
| 73 | for (const list of by.values()) { | |
| 74 | const sorted = [...list].sort( | |
| 75 | (a, b) => Date.parse(a.at) - Date.parse(b.at) || (a.id < b.id ? -1 : 1), | |
| 76 | ); | |
| 77 | let burst: Candidate[] = []; | |
| 78 | const close = () => { | |
| 79 | if (burst.length >= CREW_FOLD.min) { | |
| 80 | for (const c of burst) gone.add(c.id); | |
| 81 | folded.set(burst[burst.length - 1]!.id, joinCrew(burst)); | |
| 82 | } | |
| 83 | burst = []; | |
| 84 | }; | |
| 85 | for (const c of sorted) { | |
| 86 | if (burst.length > 0 && Date.parse(c.at) - Date.parse(burst[0]!.at) > CREW_FOLD.windowMs) close(); | |
| 87 | burst.push(c); | |
| 88 | } | |
| 89 | close(); | |
| 90 | } | |
| 91 | const out: Candidate[] = []; | |
| 92 | for (const c of candidates) { | |
| 93 | const one = folded.get(c.id); | |
| 94 | if (one) out.push(one); | |
| 95 | else if (!gone.has(c.id)) out.push(c); | |
| 96 | } | |
| 97 | return out; | |
| 98 | } | |
| 99 | ||
| 100 | function joinCrew(burst: Candidate[]): Candidate { | |
| 101 | const newest = burst[burst.length - 1]!; | |
| 102 | const runs = burst.filter((c) => c.kind === "run").length; | |
| 103 | const pulls = burst.length - runs; | |
| 104 | const counts = [runs > 0 ? plural(runs, "run") : "", pulls > 0 ? plural(pulls, "pull") : ""].filter(Boolean); | |
| 105 | const { fold: _fold, ...rest } = newest; | |
| 106 | const lines = [...burst].reverse().map((c) => ({ text: c.what, link: c.link })); | |
| 107 | return { | |
| 108 | ...rest, | |
| 109 | // The first member's id: it does not move as the burst grows, so a dismissal holds. | |
| 110 | id: `fold:crew:${burst[0]!.id.replace(/^[a-z]+:/, "")}`, | |
| 111 | kind: pulls === 0 ? "run" : runs === 0 ? "pull" : newest.kind, | |
| 112 | what: `${newest.actor}: ${counts.join(", ")}`, | |
| 113 | said: `${newest.actor}: ${counts.join(", ")}`, | |
| 114 | lines: lines.slice(0, CREW_FOLD.shown), | |
| 115 | more: Math.max(0, lines.length - CREW_FOLD.shown), | |
| 116 | opens: true, | |
| 117 | }; | |
| 118 | } | |
| 119 | ||
| 120 | function foldIssues(candidates: readonly Candidate[]): Candidate[] { | |
| 45 | 121 | const buckets = new Map<string, Candidate[]>(); |
| 46 | 122 | for (const c of candidates) { |
| 47 | 123 | if (c.fold === undefined || c.actorId === null) continue; |
src/feed/items.ts +2
| @@ -84,6 +84,8 @@ export interface Candidate { | ||
| 84 | 84 | /** On a folded post: the first titles, each linking to its own thing, and how many more. */ |
| 85 | 85 | readonly lines?: readonly { readonly text: string; readonly link: string }[]; |
| 86 | 86 | readonly more?: number; |
| 87 | /** A folded crew burst: its lines open in place (`<details>`) instead of showing. */ | |
| 88 | readonly opens?: boolean; | |
| 87 | 89 | /** For a look: the picture's environment and commit; the page finds the thumbnail. */ |
| 88 | 90 | readonly look?: FeedLook; |
| 89 | 91 | } |
src/feed/rank.ts +3
| @@ -29,6 +29,8 @@ export interface FeedItem { | ||
| 29 | 29 | /** A folded post: its first titles with their links, and how many more. */ |
| 30 | 30 | readonly lines?: readonly { readonly text: string; readonly link: string }[]; |
| 31 | 31 | readonly more?: number; |
| 32 | /** A folded crew burst: its lines open in place. */ | |
| 33 | readonly opens?: boolean; | |
| 32 | 34 | /** For a look: which picture it is of. */ |
| 33 | 35 | readonly look?: FeedLook; |
| 34 | 36 | /** For a post about a pull or an issue that is over: how it ended. Absent while open. */ |
| @@ -165,6 +167,7 @@ export function rank( | ||
| 165 | 167 | score: Math.round(bestScore * 1e6) / 1e6, |
| 166 | 168 | ...(s!.c.ask === undefined ? {} : { ask: s!.c.ask }), |
| 167 | 169 | ...(s!.c.lines === undefined ? {} : { lines: s!.c.lines, more: s!.c.more ?? 0 }), |
| 170 | ...(s!.c.opens === true ? { opens: true } : {}), | |
| 168 | 171 | ...(s!.c.look === undefined ? {} : { look: s!.c.look }), |
| 169 | 172 | }); |
| 170 | 173 | } |
src/tui/screens/feed.ts +19 −2
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | * |
| 5 | 5 | * A reply is a comment on the issue or pull the post links to: the same |
| 6 | 6 | * `addCommentThrough` the issue and pull screens call, offered on the same |
| 7 | * posts to the same people as the page's Reply (`feed/reply.ts`). `l` likes | |
| 7 | * posts to the same people as the page's Reply (`feed/reply.ts`). `enter` on a folded burst lists its members; `l` likes | |
| 8 | 8 | * the post under the cursor and a second press takes it back: the page's |
| 9 | 9 | * Like, behind the same `readInteraction` check (`feed/likes.ts`); `h` lists |
| 10 | 10 | * who liked or unliked it and when, newest first, behind that check too. Twenty |
| @@ -91,6 +91,8 @@ export function feedScreen(f: Session, ctx: Ctx): Screen { | ||
| 91 | 91 | }; |
| 92 | 92 | |
| 93 | 93 | /** `h`: the like ledger of the post under the cursor (who, act, when), newest first, `m` for older rows. */ |
| 94 | /** `enter` on a folded burst: its members, one to a line (the page's opened `<details>`). */ | |
| 95 | let members: FeedItem | null = null; | |
| 94 | 96 | let history: { item: string; lines: string[]; oldest: number; more: boolean } | null = null; |
| 95 | 97 | const showHistory = (more = false): void => { |
| 96 | 98 | const item = items[state.index]; |
| @@ -131,6 +133,7 @@ export function feedScreen(f: Session, ctx: Ctx): Screen { | ||
| 131 | 133 | return [ |
| 132 | 134 | binding(["j", "k"], "move"), |
| 133 | 135 | ...(selected() === undefined ? [] : [binding(["r"], "reply")]), |
| 136 | ...(items[state.index]?.opens === true ? [binding(["enter"], "open the burst")] : []), | |
| 134 | 137 | ...(items.length === 0 ? [] : [binding(["l"], "like")]), |
| 135 | 138 | ...(items.length === 0 ? [] : [binding(["h"], "who liked")]), |
| 136 | 139 | ...(next === null ? [] : [binding(["n"], "next page")]), |
| @@ -144,6 +147,14 @@ export function feedScreen(f: Session, ctx: Ctx): Screen { | ||
| 144 | 147 | emptyState(s, area, c, { title: EMPTY_FEED }); |
| 145 | 148 | return; |
| 146 | 149 | } |
| 150 | if (members !== null) { | |
| 151 | s.text(area.x, area.y, truncate(oneLine(members.what), area.w), style(c.fg, c.bg), area.x + area.w); | |
| 152 | const all = [...(members.lines ?? []).map((l) => l.text), ...(members.more ? [`and ${members.more} more`] : [])]; | |
| 153 | all.slice(0, Math.max(0, area.h - 3)).forEach((line, i) => | |
| 154 | s.text(area.x + 1, area.y + 2 + i, truncate(oneLine(line), area.w - 1), style(c.mutedFg, c.bg), area.x + area.w)); | |
| 155 | s.text(area.x, area.y + area.h - 1, truncate("esc back", area.w), style(c.mutedFg, c.bg), area.x + area.w); | |
| 156 | return; | |
| 157 | } | |
| 147 | 158 | if (history !== null) { |
| 148 | 159 | s.text(area.x, area.y, truncate("Likes on this post, newest first", area.w), style(c.fg, c.bg), area.x + area.w); |
| 149 | 160 | const body = history.lines.length === 0 ? ["nobody has liked it yet"] : history.lines; |
| @@ -170,6 +181,11 @@ export function feedScreen(f: Session, ctx: Ctx): Screen { | ||
| 170 | 181 | else if (out === "cancel") composer.close(); |
| 171 | 182 | return true; |
| 172 | 183 | } |
| 184 | if (members !== null) { | |
| 185 | if (e.key === "esc" || e.key === "q") members = null; | |
| 186 | ctx.invalidate(); | |
| 187 | return true; | |
| 188 | } | |
| 173 | 189 | if (history !== null) { |
| 174 | 190 | if (e.key === "esc" || e.key === "q") history = null; |
| 175 | 191 | else if (e.key === "m" && history.more) showHistory(true); |
| @@ -180,7 +196,8 @@ export function feedScreen(f: Session, ctx: Ctx): Screen { | ||
| 180 | 196 | else if (e.key === "r") { |
| 181 | 197 | const at = selected(); |
| 182 | 198 | if (at !== undefined) composer.start(`Reply on ${at.thread.owner}/${at.thread.name} #${at.thread.number}`); |
| 183 | } else if (e.key === "l") like(); | |
| 199 | } else if (e.key === "enter" && items[state.index]?.opens === true) members = items[state.index]!; | |
| 200 | else if (e.key === "l") like(); | |
| 184 | 201 | else if (e.key === "h") showHistory(); |
| 185 | 202 | else if (e.key === "t") { |
| 186 | 203 | timeline = !timeline; |
src/web/studio/feed.ts +7 −4
| @@ -150,10 +150,13 @@ export function feedCard( | ||
| 150 | 150 | ${settledBadge(item.settled)} |
| 151 | 151 | <span class="spacer"></span> |
| 152 | 152 | <span class="badge">${KIND_LABEL[item.kind]}</span>`; |
| 153 | const body = html`<p class="break"><a href="${item.link}" data-feed-open>${item.what}</a></p> | |
| 154 | ${item.lines === undefined | |
| 155 | ? null | |
| 156 | : html`<ul class="list">${item.lines.map((l) => row({ href: l.link, title: l.text }))}${item.more ? row({ title: `and ${item.more} more` }) : null}</ul>`} | |
| 153 | const lineList = item.lines === undefined | |
| 154 | ? null | |
| 155 | : html`<ul class="list">${item.lines.map((l) => row({ href: l.link, title: l.text }))}${item.more ? row({ title: `and ${item.more} more` }) : null}</ul>`; | |
| 156 | const body = html`${item.opens === true | |
| 157 | ? html`<details class="feed-fold"><summary class="break">${item.what}</summary>${lineList}</details>` | |
| 158 | : html`<p class="break"><a href="${item.link}" data-feed-open>${item.what}</a></p>`} | |
| 159 | ${item.opens === true ? null : lineList} | |
| 157 | 160 | ${picture === undefined |
| 158 | 161 | ? null |
| 159 | 162 | : html`<a class="look" data-slot="feed" href="${item.link}" tabindex="-1" aria-hidden="true" |
outdated
Duplicates lineList above; reuse it (principle: nothing says itself twice). |
src/web/studio/feed_style.ts +2
| @@ -13,6 +13,8 @@ export const feedCss = /* css */ ` | ||
| 13 | 13 | .feed .like { display: inline-block; margin: 0; } |
| 14 | 14 | .feed .like [data-likes] { display: inline-block; min-width: 3ch; text-align: end; font-variant-numeric: tabular-nums; } |
| 15 | 15 | .feed .card-foot > details { flex: 1 1 100%; } |
| 16 | /* A folded crew burst: the summary is the line; opening it grows the card in place. */ | |
| 17 | .feed-fold > summary { cursor: pointer; min-height: 44px; padding-block: 12px; display: flex; align-items: center; } | |
outdated
24px summary is tight for a thumb at 360px; add block padding. |
||
| 16 | 18 | /* Your apps: a narrow column on a desk, a tab away on a phone. */ |
| 17 | 19 | /* An admin's tick boxes are always shown (no Select toggle); 24 px to tap. */ |
| 18 | 20 | #live-apps input[type="checkbox"] { width: 24px; height: 24px; } |
test/feed-fold-bursts.test.ts added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | /** A crew member's burst of runs and pulls folds into one line that opens; people and asks | |
| 2 | * never fold. */ | |
| 3 | import { describe, expect, test } from "bun:test"; | |
| 4 | import { foldBursts } from "../src/feed/fold.ts"; | |
| 5 | import type { Candidate } from "../src/feed/items.ts"; | |
| 6 | import { feedCard } from "../src/web/studio/feed.ts"; | |
| 7 | import type { FeedItem } from "../src/feed/rank.ts"; | |
| 8 | ||
| 9 | const T0 = Date.parse("2026-11-14T10:00:00Z"); | |
| 10 | const make = (n: number, o: Partial<Candidate> = {}): Candidate => ({ | |
| 11 | id: `event:e${String(n).padStart(2, "0")}`, | |
| 12 | kind: "run", | |
| 13 | repoId: "r1", | |
| 14 | app: "a/b", | |
| 15 | actorId: "scout", | |
| 16 | actor: "scout", | |
| 17 | at: new Date(T0 + n * 60_000).toISOString(), | |
| 18 | link: `/a/b/runs/${n}`, | |
| 19 | what: `run ${n}`, | |
| 20 | urgency: 0, | |
| 21 | ...o, | |
| 22 | }); | |
| 23 | const crew = new Set(["scout"]); | |
| 24 | ||
| 25 | describe("folding a crew burst", () => { | |
| 26 | const items: Candidate[] = [ | |
| 27 | make(0), make(1), make(2, { kind: "pull", link: "/a/b/pulls/2" }), make(3), make(4, { kind: "pull", link: "/a/b/pulls/4" }), | |
| 28 | make(5, { actorId: "ann", actor: "ann" }), | |
| 29 | make(6, { actorId: "ann", actor: "ann" }), | |
| 30 | make(7, { actorId: "ann", actor: "ann" }), | |
| 31 | make(8, { urgency: 4, what: "run 8 failed" }), | |
| 32 | make(9, { kind: "ask", ask: { decision: "d", options: [], ifNothing: "", rank: 1, why: "" } }), | |
| 33 | make(10, { actorId: "mason", actor: "mason" }), | |
| 34 | make(11), | |
| 35 | ]; | |
| 36 | ||
| 37 | test("one line with counts, members at their permalinks, people and asks untouched", () => { | |
| 38 | const out = foldBursts(items, crew); | |
| 39 | const fold = out.find((c) => c.opens === true)!; | |
| 40 | expect(fold.what).toBe("scout: 4 runs, 2 pulls"); | |
| 41 | expect(fold.lines!.length).toBe(6); | |
| 42 | expect(fold.lines!.map((l) => l.link)).toContain("/a/b/pulls/2"); | |
| 43 | // newest first | |
| 44 | expect(fold.lines![0]!.link).toBe("/a/b/runs/11"); | |
| 45 | // 12 in, 6 folded to 1: 7 out | |
| 46 | expect(out.length).toBe(7); | |
| 47 | // a person's three posts, a failure, an ask and a non-crew actor all stay | |
| 48 | const ids = out.map((c) => c.id); | |
| 49 | for (const n of [5, 6, 7, 8, 9, 10]) expect(ids).toContain(`event:e${String(n).padStart(2, "0")}`); | |
| 50 | // order of the rest is kept | |
| 51 | expect(ids.filter((i) => !i.startsWith("fold:"))).toEqual( | |
| 52 | ["05", "06", "07", "08", "09", "10"].map((n) => `event:e${n}`), | |
| 53 | ); | |
| 54 | }); | |
| 55 | ||
| 56 | test("two are not a burst, and an hour apart is two bursts", () => { | |
| 57 | expect(foldBursts([make(0), make(1)], crew).length).toBe(2); | |
| 58 | const far = [make(0), make(1), make(2), make(120), make(121), make(122)]; | |
| 59 | expect(foldBursts(far, crew).filter((c) => c.opens).length).toBe(2); | |
| 60 | }); | |
| 61 | ||
| 62 | test("with no crew nothing folds", () => { | |
| 63 | expect(foldBursts([make(0), make(1), make(2)]).length).toBe(3); | |
| 64 | }); | |
| 65 | ||
| 66 | test("the card is a details, server-rendered, with each member linked", () => { | |
| 67 | const fold = foldBursts([make(0), make(1), make(2)], crew)[0]!; | |
| 68 | const item: FeedItem = { | |
| 69 | id: fold.id, kind: fold.kind, app: fold.app, actor: fold.actor, at: fold.at, link: fold.link, | |
| 70 | what: fold.what, reason: "", score: 1, lines: fold.lines!, more: fold.more!, opens: true, | |
| 71 | }; | |
| 72 | const out = String(feedCard(item)); | |
| 73 | expect(out).toContain('<details class="feed-fold"><summary class="break">scout: 3 runs</summary>'); | |
| 74 | expect(out).toContain('href="/a/b/runs/0"'); | |
| 75 | expect(out).not.toContain("<script"); | |
| 76 | }); | |
| 77 | }); |
test/told-by.test.ts +5 −2
| @@ -141,9 +141,12 @@ describe("who told and who shipped", () => { | ||
| 141 | 141 | test("the feed item says who told", async () => { |
| 142 | 142 | await answered(repo, "feed"); |
| 143 | 143 | const items = readFeed(app.db, alice, { limit: 100 }).items; |
| 144 | // Scout's earlier pulls make a burst, which folds. The told-by words stay in each member's | |
| 145 | // line on purpose (a burst has many tellers, so the fold's own line cannot name one). | |
| 146 | const folds = items.filter((i) => i.opens === true); | |
| 147 | expect(folds.length).toBeGreaterThan(0); | |
| 144 | 148 | expect( |
| 145 | items.some((i) => i.reason.includes("Notes, feed") && | |
| 146 | i.reason.includes("(told by bob, by scout)")), | |
| 149 | folds.some((f) => (f.lines ?? []).some((l) => l.text.includes("(told by bob, by scout)"))), | |
| 147 | 150 | ).toBe(true); |
| 148 | 151 | }); |
| 149 | 152 |
test/tui-feed-fold.test.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** | |
| 2 | * The feed in the terminal folds a crew member's burst the way the page does: one line with | |
| 3 | * counts, and `enter` on it lists the members, `esc` closes the list. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 10 | import { openPull } from "../src/actions/pulls.ts"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 14 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 15 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | import { promote } from "./helpers/members.ts"; | |
| 18 | import { settleApp } from "./helpers/tui.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | let dir = ""; | |
| 23 | let forge: App; | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | dir = await mkdtemp(join(tmpdir(), "forge-tui-feed-fold-")); | |
| 27 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 28 | const made = await createUser(forge.db, { | |
| 29 | origin: "seed", trusted: true, allowReserved: true, username: "alice", email: "[email protected]", | |
| 30 | password: "a-good-passphrase", adminIfFirst: true, | |
| 31 | }); | |
| 32 | if (!made.ok) throw new Error(made.error); | |
| 33 | promote(forge.db, made.value); | |
| 34 | const alice = getUserByName(forge.db, "alice")!; | |
| 35 | const repo = await createRepo(forge, alice, { name: "open", visibility: "public" }); | |
| 36 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 37 | await commitToBranch(forge, alice, repo.value, { message: "seed", files: [{ path: "README.md", content: "# o\n" }] }); | |
| 38 | const agent = await createAgent(forge, alice, { username: "scout" }); | |
| 39 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 40 | addToCrew(forge, alice, repo.value, "scout"); | |
| 41 | const scout = getUserByName(forge.db, "scout")!; | |
| 42 | for (const n of [1, 2, 3]) { | |
| 43 | await commitToBranch(forge, alice, repo.value, { | |
| 44 | branch: `idea${n}`, base: "main", message: `idea ${n}`, files: [{ path: `i${n}.md`, content: `${n}\n` }], | |
| 45 | }); | |
| 46 | const pull = await openPull(forge, scout, repo.value, { | |
| 47 | title: `Idea ${n}`, body: "", baseBranch: "main", headBranch: `idea${n}`, | |
| 48 | }); | |
| 49 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 50 | } | |
| 51 | }); | |
| 52 | ||
| 53 | afterAll(async () => { | |
| 54 | forge.db.close(); | |
| 55 | await rm(dir, { recursive: true, force: true }); | |
| 56 | }); | |
| 57 | ||
| 58 | test("enter on the folded line lists its members; esc closes the list", async () => { | |
| 59 | const terminal = headlessTerminal(140, 32); | |
| 60 | const identity = { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(forge.db, "alice")! }; | |
| 61 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 62 | app.start(); | |
| 63 | const press = async (...keys: string[]) => { | |
| 64 | for (const k of keys) app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 65 | await settleApp(app); | |
| 66 | }; | |
| 67 | app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false }); | |
| 68 | await settleApp(app); | |
| 69 | await press(..."Feed", "enter"); | |
| 70 | expect(app.screen.toText()).toContain("scout: 3 pulls"); | |
| 71 | expect(app.screen.toText()).not.toContain("Idea 2"); | |
| 72 | await press("enter"); | |
| 73 | const opened = app.screen.toText(); | |
| 74 | for (const n of [1, 2, 3]) expect(opened).toContain(`Idea ${n}`); | |
| 75 | await press("esc"); | |
| 76 | expect(app.screen.toText()).toContain("scout: 3 pulls"); | |
| 77 | expect(app.screen.toText()).not.toContain("Idea 2"); | |
| 78 | app.stop(); | |
| 79 | }); |
new above
offline
The newest member reason (told by ...) is dropped on fold; carry it or justify it.