The web UI is implemented in internal/webui/ as a plain net/http server with a JSON API and go:embed-ed frontend assets.
The ghx serve CLI command in cmd/serve.go resolves the cache store, starts the server, and handles shutdown.
The frontend is a Vue 3 single-page app (loaded from CDN, no build step) styled with daisyUI 5 (also via CDN), with hand-rolled hash routing, a GitHub-style list and detail view, and a Ctrl+P palette.
Architecture
ghx serve ──► runServe()
│
├─ store := newStore() ──► cache.Store (file backend)
│
├─ webui.New(store, refreshFn) ──► *webui.Server
│ │
│ ├─ GET / ──► index.html (embedded)
│ ├─ GET /assets/* ──► embedded FS (app.js, style.css)
│ ├─ GET /api/repos ──► ListCachedRepos + counts
│ ├─ GET /api/repos/{host}/{owner}/{repo}/issues?state=&q=
│ ├─ GET /api/repos/{host}/{owner}/{repo}/prs?state=&q=
│ ├─ GET /api/repos/{host}/{owner}/{repo}/issues/{n}
│ ├─ GET /api/repos/{host}/{owner}/{repo}/prs/{n}
│ ├─ POST /api/repos/{host}/{owner}/{repo}/refresh
│ └─ GET /api/search?q=&scope=
│
└─ http.Server.ListenAndServe + signal-based shutdown
The refresh handler calls back into the shared fetch logic extracted from cmd/cache.go, so ghx cache and the UI refresh button use one implementation of the resumable/delta fetch.
Data Models
RepoSummary (GET /api/repos entries)
Field
Type
Description
host
string
GitHub host (e.g. github.com)
owner
string
Repository owner
repo
string
Repository name
issueCount
int
Number of cached issues
prCount
int
Number of cached PRs
cachedAt
string/null
RFC3339 timestamp from cache info, null when absent
complete
bool
Whether the last fetch completed
ItemSummary (list entries)
Field
Type
Description
number
int
Issue or PR number
title
string
Title
state
string
open, closed, or merged (PRs)
isDraft
bool
PR draft flag (issues always false)
kind
string
issue or pr
labels
array
{name, color} entries
author
string
Author login
commentCount
int
Comment count
updatedAt
string
RFC3339 timestamp
url
string
GitHub URL
ItemDetail
Field
Type
Description
summary
ItemSummary
As above
bodyHTML
string
Goldmark-rendered body HTML
assignees
array
Actor logins
milestone
object/null
{number, title}
createdAt
string
RFC3339 timestamp
closedAt/mergedAt
string/null
RFC3339 timestamps when set
baseRefName/headRefName
string
PR branch names (empty for issues)
comments
array
{author, createdAt, bodyHTML, url} entries
API Contracts
GET /api/repos
Returns {"repos": [RepoSummary...]} sorted by owner then repo name.
GET /api/repos/{host}/{owner}/{repo}/issues|prs?state=&q=
state: open (default), closed, all; PRs additionally accept merged
q: case-insensitive substring match on title and body
Returns {"items": [ItemSummary...]} sorted by updatedAt descending
Unknown repository returns 404
GET /api/repos/{host}/{owner}/{repo}/issues|prs/{number}
Returns ItemDetail as JSON; 404 when the item is not cached.
POST /api/repos/{host}/{owner}/{repo}/refresh
Triggers a forced (resumable) fetch of issues and PRs for the repository
Returns 200 with {"issues": n, "prs": m} on success
Returns 409 while another refresh for the same repository is in flight
Requires a GitHub client (honours --api-url for testing)
List view (tab + filter in query: #/owner/repo?tab=issues&state=open)
#/{owner}/{repo}/issues/{n}
Issue detail
#/{owner}/{repo}/pulls/{n}
PR detail
Components
Component
Responsibility
Root app
Reactive store: repos, current repo, tab, state filter, items, detail, theme, refreshing map; hash parsing
RepoSelect
Dropdown of cached repositories with issue/PR counts
ItemList
GitHub-style rows: state icon (open green, closed purple for issues, merged purple, draft grey for PRs), number, title, colored label chips, author, comment count, updated date
ItemDetail
State badge, title, meta line, sidebar (labels, assignees, milestone, GitHub link), rendered markdown body, comment timeline
ThemeToggle
Switches daisyUI's data-theme between light and dark; persists to localStorage; defaults to the system preference
Palette
Overlay triggered by Ctrl+P/Cmd+P or /; fuzzy filter over #N title entries; arrow keys + Enter to jump, Esc to close
Fuzzy matching
Palette matching scores entries by: exact number match, prefix match on title words, and subsequence match, in that order of precedence; ties break by updatedAt descending.
Technical Decisions
Decision
Choice
Rationale
Framework
Vue 3 via CDN (vue.global.prod.js, pinned major)
No build step, reactive state for list/detail/palette, user preference
CSS
daisyUI 5 via CDN (daisyui.css) plus the Tailwind v4 browser build for utility classes
Component and utility classes without a build step; ghx-owned style.css is a small override layer (markdown typography, navbar helpers, palette rows)
Router
Hand-rolled hash parsing
Only 3 route shapes; avoids a second CDN dependency
Markdown
goldmark with GFM extensions, raw HTML disabled
Server-side rendering keeps the frontend dumb; safe by default
Assets
go:embed for ghx-owned files
Single-binary distribution; only Vue, daisyUI, and the Tailwind browser build load from CDN
Refresh
Shared fetch function with per-repo mutex
One implementation of resume logic (NFR-03); prevents parallel fetch clobbering (NFR-02)
Binding
Loopback by default
The API exposes cached data and a mutation trigger; no auth layer is planned
Risks and Unknowns
CDN availability at first page load; mitigated by an inline "framework failed to load" message and a JSON API that works without the UI
Refresh against the real API is slow for large repositories; the request is long-running by design and guarded against concurrency
The Tailwind browser build generates utility classes at runtime, so there can be a brief unstyled flash on first load
Out of Scope
Creating, editing, or commenting on issues and PRs from the UI
GitHub search syntax in the palette (substring/fuzzy matching only)