// Pull Requests page — Row-list style list view over `gh pr list` JSON.
//
// Layout (top-to-bottom):
// 1. Page header: "Pull Requests" + subtitle (repo slug or connect hint)
// 2. Search input + filter chips (All / Open / Mine)
// 3. Grouped list:
// Open — one row per PR
// Recently merged/closed — collapsed below
// Each row: state dot · title · repo·branch subrow · relative time · diff totals · "Ask DSH".
//
// The renderer owns all DOM; main.js owns the gh spawn + 60s cache. The
// module registers a `mount()` function on window.__dshPRs that renderer.js's
// tab switcher calls whenever the tab activates, plus a fetch on first mount
// so the initial view isn't blank while the daemon is starting.
/* global window, document */
;(function () {
'use strict'
// Live state kept minimal: last fetched dataset + current filter + query.
// The rendered DOM is re-derived on every change so the layout stays honest
// (no cell-level updates to hunt through when the shape changes).
const state = {
data: null, // { rows, repo, source, error, fetchedAt }
filter: 'all', // 'all' | 'open' | 'mine'
query: '', // free-text substring, case-insensitive
viewer: '', // gh viewer login (best-effort; empty ⇒ "mine" is empty)
loading: false,
}
function el(tag, attrs = {}, children = []) {
const node = document.createElement(tag)
for (const [k, v] of Object.entries(attrs)) {
if (k === 'className') node.className = v
else if (k === 'text') node.textContent = v
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v)
else if (v === true) node.setAttribute(k, '')
else if (v === false || v == null) { /* skip */ }
else node.setAttribute(k, v)
}
for (const c of [].concat(children)) {
if (c == null || c === false) continue
node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c)
}
return node
}
// Small SVG helper — Row-list style PR mark with a branch curve. `stateDot`
// decides the stroke colour via a CSS class; the icon itself stays neutral.
function prIcon() {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '0 0 16 16')
svg.setAttribute('width', '16')
svg.setAttribute('height', '16')
svg.setAttribute('aria-hidden', 'true')
svg.setAttribute('class', 'pr-icon')
svg.innerHTML =
'' +
'' +
'' +
'' +
''
return svg
}
// Send arrow — visually mirrors the quick chat button. Reused by the
// per-row "Ask DSH" so the arrow direction is consistent with the composer.
function sendGlyph() {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '0 0 16 16')
svg.setAttribute('width', '12')
svg.setAttribute('height', '12')
svg.setAttribute('aria-hidden', 'true')
svg.innerHTML =
''
return svg
}
// Relative time formatter — same logic as the pure module in main. Kept
// duplicated here so the renderer stays script-tag-friendly (no require).
function relTime(iso, now = new Date()) {
if (!iso) return ''
const t = Date.parse(iso)
if (!Number.isFinite(t)) return ''
const diff = Math.max(0, now.getTime() - t)
const s = Math.floor(diff / 1000)
if (s < 45) return 'just now'
const m = Math.floor(s / 60); if (m < 60) return `${m}m`
const h = Math.floor(m / 60); if (h < 24) return `${h}h`
const d = Math.floor(h / 24); if (d < 7) return `${d}d`
const w = Math.floor(d / 7); if (w < 5) return `${w}w`
const mo = Math.floor(d / 30); if (mo < 12) return `${mo}mo`
return `${Math.floor(d / 365)}y`
}
function renderRow(row, repoSlug) {
// Row = a full-width button so ↑↓ arrow navigation with focus rings can
// land later without restructuring the DOM. Body click opens the PR
// externally; the trailing "Ask DSH" swallows its own click so it never
// races the row.
const dot = el('span', { className: `pr-state-dot pr-state-${row.stateDot}`, title: row.state })
const title = el('span', { className: 'pr-title', text: row.title })
const num = el('span', { className: 'pr-num', text: `#${row.number}` })
const branch = el('span', { className: 'pr-branch', text: row.headRefName })
const arrow = el('span', { className: 'pr-branch-sep', text: '→' })
const base = el('span', { className: 'pr-base', text: row.baseRefName || 'master' })
const repoLbl = el('span', {
className: 'pr-repo muted',
text: repoSlug || '',
})
// adds/dels — Reference apps render these right-aligned with a subtle green/red;
// we do the same via .pr-diff-adds / .pr-diff-dels.
const diff = el('span', { className: 'pr-diff' }, [
el('span', { className: 'pr-diff-adds', text: `+${row.additions}` }),
el('span', { className: 'pr-diff-dels', text: `−${row.deletions}` }),
])
const rel = el('span', { className: 'pr-rel muted', text: relTime(row.updatedAt) })
const author = row.authorLogin
? el('span', { className: 'pr-author muted', text: `@${row.authorLogin}` })
: null
const askBtn = el('button', {
className: 'pr-ask ghost small',
title: 'Start a DSH chat about this PR',
'aria-label': `Ask DSH about PR #${row.number}`,
onclick: (ev) => {
ev.stopPropagation()
void askDshAboutPR(row)
},
}, [sendGlyph(), el('span', { text: 'Ask DSH' })])
const rowNode = el('button', {
className: `pr-row pr-row-${row.stateDot}`,
type: 'button',
onclick: () => openPR(row),
}, [
el('span', { className: 'pr-row-lead' }, [prIcon(), dot]),
el('span', { className: 'pr-row-body' }, [
el('span', { className: 'pr-row-titleline' }, [title, num]),
el('span', { className: 'pr-row-subline' }, [
repoLbl,
repoSlug ? el('span', { className: 'pr-dotsep', text: '·' }) : null,
branch, arrow, base,
author ? el('span', { className: 'pr-dotsep', text: '·' }) : null,
author,
]),
]),
el('span', { className: 'pr-row-trail' }, [diff, rel, askBtn]),
])
return rowNode
}
async function openPR(row) {
if (!row.url) return
try { await window.dsh.openExternalUrl(row.url) }
catch (err) { console.warn('openExternalUrl failed:', err) }
}
// "Ask DSH" is the interesting bit: instead of the reference design's "open review in
// browser" we mint a new DSH session, seed it with a scoped prompt, and
// switch to the Chat tab. This is the wedge we want people to notice on the
// page — the PR list is an entry point into an agent, not a static list.
async function askDshAboutPR(row) {
try {
const { id } = await window.dsh.newSession()
const prompt = `Review PR #${row.number}: ${row.title}` +
`\n\nBranch: ${row.headRefName} → ${row.baseRefName || 'master'}` +
(row.url ? `\nLink: ${row.url}` : '') +
`\n\nStart by summarizing the diff, then flag anything that needs a second look.`
if (window.__dshChat && typeof window.__dshChat.selectSession === 'function') {
// Register into the sidebar with a helpful title so the user can
// recognize it in the session list.
await window.__dshChat.selectSession(id)
}
if (window.__dshTabs && typeof window.__dshTabs.switchTo === 'function') {
window.__dshTabs.switchTo('chat')
}
await window.dsh.sendPrompt(id, prompt)
} catch (err) {
console.error('Ask DSH failed:', err)
alert(`Ask DSH failed: ${err.message}`)
}
}
// Client-side filter: server returns everything, we sift here so the
// filter chip is instant. `query` matches title, branch, author, PR#.
function filterRows(rows) {
const q = state.query.trim().toLowerCase()
return rows.filter((r) => matchesFilter(r, state.filter, state.viewer) && matchesQuery(r, q))
}
// Pulled out of filterRows so the same predicates drive the chip counts
// (below) and the visible list. Keeping them side-by-side means the count
// beside a chip is always the size of the list you'd see after clicking it.
function matchesFilter(r, filter, viewer) {
if (r.dropped) return false
if (filter === 'open' && r.state !== 'OPEN') return false
if (filter === 'mine') {
if (!viewer) return false
if (r.authorLogin.toLowerCase() !== viewer.toLowerCase()) return false
}
return true
}
function matchesQuery(r, q) {
if (!q) return true
return (
r.title.toLowerCase().includes(q) ||
r.headRefName.toLowerCase().includes(q) ||
r.authorLogin.toLowerCase().includes(q) ||
String(r.number).includes(q)
)
}
// Chip counts reflect "how many rows would this chip surface if you
// pressed it *now*" — so they honour the current search query, not just
// the raw dataset. That makes the count useful even when the user is
// typing to narrow the list.
function computeChipCounts(rows, viewer, query) {
const q = (query || '').trim().toLowerCase()
const live = rows.filter((r) => !r.dropped && matchesQuery(r, q))
return {
all: live.length,
open: live.filter((r) => r.state === 'OPEN').length,
mine: viewer
? live.filter((r) => r.authorLogin.toLowerCase() === viewer.toLowerCase()).length
: 0,
}
}
// Update the counter pill on each filter chip. Hidden when the count is
// zero to avoid stamping "0" onto a chip that just happens to be inactive.
function renderChipCounts(counts) {
for (const key of Object.keys(counts)) {
const el = document.querySelector(`[data-pr-count-for="${key}"]`)
if (!el) continue
const n = counts[key]
if (n > 0) { el.textContent = String(n); el.hidden = false }
else { el.textContent = ''; el.hidden = true }
}
}
function renderEmpty(container, message, hint) {
container.appendChild(el('div', { className: 'pr-empty' }, [
el('div', { className: 'pr-empty-title', text: message }),
hint ? el('div', { className: 'pr-empty-hint muted', text: hint }) : null,
]))
}
function renderConnectHint(container, reason) {
container.appendChild(el('div', { className: 'pr-hint-banner' }, [
el('span', { className: 'pr-hint-badge', text: 'demo data' }),
el('span', { text: ' — install and sign in with the GitHub CLI to see live PRs (' }),
el('code', { text: 'gh auth login' }),
el('span', { text: `). Reason: ${reason || 'unknown'}` }),
]))
}
function renderList() {
const list = document.getElementById('pr-list')
const meta = document.getElementById('pr-meta')
const subtitle = document.getElementById('pr-subtitle')
if (!list || !meta || !subtitle) return
list.innerHTML = ''
meta.innerHTML = ''
const d = state.data
if (state.loading && !d) {
subtitle.textContent = 'Loading…'
renderEmpty(list, 'Fetching PRs…', 'This runs `gh pr list` in the current repo.')
return
}
if (!d) {
subtitle.textContent = 'Not loaded'
return
}
subtitle.textContent = d.repo
? `${d.repo} — ${d.rows.length} PR${d.rows.length === 1 ? '' : 's'} loaded`
: (d.source === 'demo'
? 'Not connected to a repo — showing demo data'
: 'Loaded')
if (d.source === 'demo') renderConnectHint(meta, d.error)
// Chip counts are refreshed on every render — cheap since it's three
// filter predicates over an in-memory array. Kept next to renderList
// so any dataset change (fetch, filter, search) reflects in the pills.
renderChipCounts(computeChipCounts(d.rows, state.viewer, state.query))
const rows = filterRows(d.rows)
const open = rows.filter((r) => r.state === 'OPEN')
const closed = rows.filter((r) => r.state !== 'OPEN')
if (open.length === 0 && closed.length === 0) {
renderEmpty(list, state.query ? 'No PRs match your search.' : 'No PRs to show.',
state.query ? 'Try clearing the search or switching the filter.' : null)
return
}
if (open.length > 0) {
list.appendChild(el('div', { className: 'pr-group-head', text: 'Open' }))
const group = el('div', { className: 'pr-group' })
for (const row of open) group.appendChild(renderRow(row, d.repo))
list.appendChild(group)
}
if (closed.length > 0) {
list.appendChild(el('div', { className: 'pr-group-head pr-group-head-closed', text: 'Recently merged or closed' }))
const group = el('div', { className: 'pr-group' })
for (const row of closed) group.appendChild(renderRow(row, d.repo))
list.appendChild(group)
}
}
async function fetchList(force) {
if (state.loading) return
state.loading = true
renderList()
try {
const res = await (force ? window.dsh.prs.refresh() : window.dsh.prs.list())
state.data = res || { rows: [], source: 'unknown' }
state.viewer = res && res.viewer ? res.viewer : ''
} catch (err) {
// A hard failure still ends up as a demo-source dataset so the tab
// never sits blank — the connect banner explains why.
console.error('prs.list failed:', err)
state.data = { rows: [], source: 'demo', error: err.message }
} finally {
state.loading = false
renderList()
}
}
function bindControls() {
const search = document.getElementById('pr-search')
if (search) {
search.addEventListener('input', () => { state.query = search.value; renderList() })
}
for (const chip of document.querySelectorAll('.pr-filter-chip')) {
chip.addEventListener('click', () => {
state.filter = chip.dataset.prFilter
// Keep visual state (`.active`) and ARIA state (`aria-selected`)
// in lockstep — screen readers only pick up the second, the eye
// only sees the first.
for (const c of document.querySelectorAll('.pr-filter-chip')) {
const isActive = c === chip
c.classList.toggle('active', isActive)
c.setAttribute('aria-selected', isActive ? 'true' : 'false')
}
renderList()
})
}
// Two refresh entrypoints — sidebar icon-btn and header button — same
// action. Binding both lets the user reach it from wherever they are
// in the layout without needing to remember which side owns which.
for (const id of ['pr-refresh', 'pr-refresh-btn']) {
const btn = document.getElementById(id)
if (btn) btn.addEventListener('click', () => { void fetchList(true) })
}
}
// Guard the DOM-touching handle: when this file is required from node
// --test the module boots for its pure helpers (`_internal` below), but
// there is no `window` for us to hang mount/show/refresh on.
if (typeof window !== 'undefined') {
window.__dshPRs = {
mount() {
bindControls()
// Kick a background fetch on first mount; subsequent tab switches use
// the cached dataset unless the user hits Refresh (which forces).
if (!state.data) void fetchList(false)
},
// Called by the tab switcher every time the user opens the tab. Cheap
// when data is cached; kicks the first fetch if mount() ran before this
// script's controls were reachable (script-order races leave state.data
// null and the page stuck on "Not loaded" otherwise).
show() {
if (!state.data && !state.loading) { void fetchList(false); return }
renderList()
},
// Test hook: forces a full reload from gh.
refresh() { return fetchList(true) },
}
}
// Pure-fn export seam for node --test. Matches mission-topo._internal:
// ship only the deterministic helpers (matchesFilter / matchesQuery /
// computeChipCounts / relTime) so tests can hit the branchy bits without
// spinning up jsdom. Not touched by product code — the IIFE's closure
// continues to serve the renderer.
if (typeof module !== 'undefined' && module.exports) {
module.exports = { _internal: { matchesFilter, matchesQuery, computeChipCounts, relTime } }
}
})()