diff --git a/fish/functions/herdr-screens.fish b/fish/functions/herdr-screens.fish index 8a85cfe..41f68f2 100644 --- a/fish/functions/herdr-screens.fish +++ b/fish/functions/herdr-screens.fish @@ -23,7 +23,8 @@ function herdr-screens --description 'Open extra Herdr Desktop screens for multi set -l desktop "$repo/herdr/desktop" set -l shared themes/refactoria.json layouts/refactoria.json \ mods/refactoria-spaces/main.js mods/refactoria-spaces/mod.json \ - mods/refactoria-agents/main.js mods/refactoria-agents/mod.json + mods/refactoria-agents/main.js mods/refactoria-agents/mod.json \ + mods/refactoria-prs/main.js mods/refactoria-prs/mod.json for n in (seq 2 $count) set -l home "$HOME/.config/herdr-app-$n" diff --git a/herdr/desktop/layouts/refactoria.json b/herdr/desktop/layouts/refactoria.json index 39a3514..1ecdf78 100644 --- a/herdr/desktop/layouts/refactoria.json +++ b/herdr/desktop/layouts/refactoria.json @@ -37,10 +37,10 @@ "min": 90 }, { - "id": "agents", + "id": "prs", "type": "block", "content": { - "mod": "refactoria-agents" + "mod": "refactoria-prs" }, "size": { "weight": 1 diff --git a/herdr/desktop/mods/refactoria-prs/main.js b/herdr/desktop/mods/refactoria-prs/main.js new file mode 100644 index 0000000..3b4abf7 --- /dev/null +++ b/herdr/desktop/mods/refactoria-prs/main.js @@ -0,0 +1,107 @@ +// Open pull requests of the active space's repo, with their checks. The repo is +// read from git where the space lives (any machine); `gh` asks GitHub from here, +// so remote machines need no GitHub login. Click a row to open it in the browser. + +import { View, div } from "gpui-kit"; +import { v_flex, h_flex } from "gpui-base"; +import { spaces, panes, exec, theme } from "herdr"; + +// GitHub's rate limit is generous, but every space switch and this fallback ask again. +const EVERY = 60000; +const FIELDS = "number,title,url,isDraft,headRefName,statusCheckRollup"; + +// Where a space is now: its first terminal's live folder, else where it was made. +function place(s) { + const tabs = new Set((s.tabs ?? []).map((c) => c.id)); + return panes().find((p) => tabs.has(p.tab) && p.cwd)?.cwd || s.folder; +} + +// owner/name from an origin URL (ssh, https, or an ssh host alias). +export function slug(url) { + const m = url.trim().match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?\/?$/); + return m ? m[1] : null; +} + +// One state for all of a PR's checks: a failure wins, then anything still running. +export function checks(rollup) { + if (!rollup?.length) return "none"; + const states = rollup.map((c) => (c.__typename === "StatusContext" ? c.state : c.status !== "COMPLETED" ? "PENDING" : c.conclusion)); + if (states.some((s) => ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(s))) return "fail"; + if (states.some((s) => ["PENDING", "EXPECTED", "QUEUED", "IN_PROGRESS", null, undefined].includes(s))) return "pending"; + return "pass"; +} + +export default class PullRequests extends View { + init(_props, cx) { + this.key = null; // the active space's [machine, folder] last asked about + this.repo = null; // owner/name, null: not a GitHub repo + this.prs = null; // null: not asked yet + this.error = null; + this.at = 0; + cx.timer.every(1000, (cx) => { + const s = spaces().find((x) => x.active); + const key = s ? JSON.stringify([s.machine ?? null, place(s)]) : null; + if (key !== this.key || Date.now() - this.at > EVERY) this.refresh(key, cx); + }); + } + + refresh(key, cx) { + if (this.loading) return; + const switched = key !== this.key; + this.key = key; + this.at = Date.now(); + if (switched) { this.prs = null; this.repo = null; this.error = null; cx.notify(); } + if (!key) return; + this.loading = true; + const [on, cwd] = JSON.parse(key); + cx.spawn(async (cx) => { + try { + const o = await exec("git", ["remote", "get-url", "origin"], { cwd, on: on ?? undefined }); + const repo = o.code === 0 ? slug(o.stdout) : null; + if (key !== this.key) return; + this.repo = repo; + if (!repo) { this.prs = []; this.error = null; return; } + const r = await exec("gh", ["pr", "list", "-R", repo, "--state", "open", "--limit", "20", "--json", FIELDS]); + if (key !== this.key) return; + if (r.code === 0) { this.prs = JSON.parse(r.stdout); this.error = null; } + else this.error = r.stderr.trim().split("\n").pop() || "gh failed"; + } catch { + // The machine is away or gh is missing: keep what was shown, try again later. + if (key === this.key) this.error = "Could not reach the repo"; + } finally { + this.loading = false; + cx.notify(); + } + }); + } + + row(t, pr) { + const state = checks(pr.statusCheckRollup); + const color = { pass: t.ansi2, fail: t.ansi1, pending: t.blocked, none: t.dim }[state]; + return h_flex().id(`pr-${pr.number}`).h(44).flex_none().px(8).gap(8).items_center().rounded(8).cursor_pointer() + .hover((s) => s.bg(t.hover)) + .on_click(() => exec("open", [pr.url])) + .child(div().size(8).rounded(4).flex_none().bg(color)) + .child(v_flex().flex_1().min_w(0) + .child(div().truncate().text_color(pr.isDraft ? t.dim : t.foreground).child(pr.title)) + .child(div().truncate().text_size(12).text_color(t.dim).child(`#${pr.number} · ${pr.isDraft ? "draft · " : ""}${pr.headRefName}`))); + } + + render(cx) { + const t = theme(); + const note = (text) => div().px(8).py(6).text_size(12).text_color(t.dim).child(text); + let body; + if (!this.key) body = [note("No space selected")]; + else if (this.prs === null && !this.error) body = [note("Loading…")]; + else if (!this.repo && !this.error) body = [note("Not a GitHub repo")]; + else if (this.prs?.length) body = this.prs.map((pr) => this.row(t, pr)); + else if (!this.error) body = [note("No open pull requests")]; + else body = []; + if (this.error) body.push(note(this.error)); + return v_flex().size_full().min_h(0).bg(t.chrome).text_size(13) + .child(h_flex().h(31).pl(16).pr(10).items_center().justify_between().text_size(12).text_color(t.dim) + .child("Pull requests") + .when(Boolean(this.repo), (el) => el.child(div().truncate().child(this.repo)))) + .child(v_flex().flex_1().min_h(0).px(8).pb(14).overflow_y_scrollbar().mode("always").children(body)); + } +} diff --git a/herdr/desktop/mods/refactoria-prs/mod.json b/herdr/desktop/mods/refactoria-prs/mod.json new file mode 100644 index 0000000..eacba36 --- /dev/null +++ b/herdr/desktop/mods/refactoria-prs/mod.json @@ -0,0 +1,4 @@ +{ + "place": "none", + "title": "Pull requests" +} diff --git a/install.sh b/install.sh index c8103ac..b619ec1 100755 --- a/install.sh +++ b/install.sh @@ -38,6 +38,8 @@ MANAGED_LINKS=( "herdr/desktop/mods/refactoria-spaces/mod.json|$HOME/.config/herdr-app/mods/refactoria-spaces/mod.json|darwin" "herdr/desktop/mods/refactoria-agents/main.js|$HOME/.config/herdr-app/mods/refactoria-agents/main.js|darwin" "herdr/desktop/mods/refactoria-agents/mod.json|$HOME/.config/herdr-app/mods/refactoria-agents/mod.json|darwin" + "herdr/desktop/mods/refactoria-prs/main.js|$HOME/.config/herdr-app/mods/refactoria-prs/main.js|darwin" + "herdr/desktop/mods/refactoria-prs/mod.json|$HOME/.config/herdr-app/mods/refactoria-prs/mod.json|darwin" "ghostty/shaders|$HOME/.config/ghostty/shaders|all" "ghostty/profiles/barbatdev.conf|$HOME/.config/ghostty/profiles/barbatdev.conf|darwin" "ghostty/profiles/refactoria.conf|$HOME/.config/ghostty/profiles/refactoria.conf|darwin"