From dabeb0bafb10f5a964bb6d32300a1886bb626697 Mon Sep 17 00:00:00 2001 From: Derek Reynolds Date: Tue, 9 Jun 2026 20:43:33 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20shareable=20issue=20permalinks=20(/i/{s?= =?UTF-8?q?hortID})=20=E2=80=94=20issue-as-resource?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-linking the hypermedia way: the ISSUE is the resource and gets a canonical URL; the session stays the resource, so the URL is never mirrored/rewritten as you navigate. - GET /i/{shortID}: SSR-renders the app focused on the issue (no flash) via a shared renderPage(initial, seedSelect) extracted from handleIndex; 404 for missing/bad ids (a real resource 404). - The page's data-init seeds the live session before opening the stream (@post('/cmd/select?id=N').then(() => @get('/stream'))), so the projection agrees with first paint — no reconcile flash, race-free. - The detail's #N is a real permalink that tracks the open issue (pull on demand); navigating within the session does not change the URL. Verified: /i/1246 opens the issue with the stream live; missing/bad ids 404; opening another issue repoints the permalink to /i/1217 while the URL stays /i/1246. Refs tasks/deep-linkable-urls.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.go | 54 ++++++++++++++++++++++++++++------------ static/app.css | 2 ++ templates/fragments.html | 2 +- templates/index.html | 2 +- 4 files changed, 42 insertions(+), 18 deletions(-) diff --git a/main.go b/main.go index fe42aa6..2d3fe41 100644 --- a/main.go +++ b/main.go @@ -207,6 +207,7 @@ func main() { staticFile.ServeHTTP(w, r) })) pub.HandleFunc("GET /", a.handleIndex) + pub.HandleFunc("GET /i/{id}", a.handleIssue) // canonical shareable issue permalink pub.HandleFunc("GET /stream", a.handleStream) // GitHub OAuth (full-page redirects, not Datastar commands). @@ -429,31 +430,52 @@ func (a *app) handleIndex(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } + a.renderPage(w, r, newSessionState(), 0) +} + +// handleIssue serves the canonical, shareable URL for an issue: /i/{shortID}. The +// issue is the resource; this entry point SSR-renders the app focused on it and +// seeds the live session (via seedSelect) so the stream agrees with first paint. +// The session — not the URL — remains the resource, so we never rewrite the URL +// as the user navigates afterwards. +func (a *app) handleIssue(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil || id <= 0 { + http.NotFound(w, r) + return + } + if d, derr := GetIssue(a.reads, id); derr != nil || d == nil { + http.NotFound(w, r) // a real resource 404, not a soft empty state + return + } + st := newSessionState() + st.Selected = id + a.renderPage(w, r, st, id) +} + +// renderPage server-renders the COMPLETE page for the given initial view, so first +// paint never depends on the SSE stream (WebKit buffers streamed fetches; the +// stream only drives live *updates*). seedSelect (>0 on /i/{id}) tells the page's +// data-init to adopt that issue on the live session before opening the stream. +func (a *app) renderPage(w http.ResponseWriter, r *http.Request, initial SessionState, seedSelect int) { vw := a.viewerFor(r) - // Server-render the initial regions so the page is COMPLETE on first paint — - // no blank shell while the SSE stream connects. WebKit/Safari buffers a - // streamed fetch response and can delay delivery indefinitely (even the - // hyperlith reference demo hits this), so depending on the stream for first - // content is fragile; here the stream only drives live *updates*, morphing - // these already-rendered regions. The initial render is the default - // (fresh-session) view; a returning tab's stream reconciles to its own - // filters/selection on connect. Auth chrome is rendered from the viewer. - rs, err := a.renderRegions(newSessionState(), vw) + rs, err := a.renderRegions(initial, vw) if err != nil { - log.Printf("index render: %v", err) + log.Printf("page render: %v", err) http.Error(w, "error", 500) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") data := struct { viewer - AssetVer string - Facets template.HTML - List template.HTML - Detail template.HTML - }{vw, assetVer, template.HTML(rs.facets), template.HTML(rs.list), template.HTML(rs.detail)} + AssetVer string + SeedSelect int + Facets template.HTML + List template.HTML + Detail template.HTML + }{vw, assetVer, seedSelect, template.HTML(rs.facets), template.HTML(rs.list), template.HTML(rs.detail)} if err := tmpl.ExecuteTemplate(w, "index.html", data); err != nil { - log.Printf("index: %v", err) + log.Printf("page: %v", err) http.Error(w, "error", 500) } } diff --git a/static/app.css b/static/app.css index 35e473d..dd75ca1 100644 --- a/static/app.css +++ b/static/app.css @@ -149,6 +149,8 @@ body { background: var(--bg); color: var(--fg); font-size: 14px; } .edit-status input[type="radio"]:checked::before { transform: scale(1); } .edit-status input[type="radio"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .issue-sub { color: var(--muted); font-size: 12px; margin: 8px 0; } +.permalink { color: var(--muted); text-decoration: none; } +.permalink:hover { color: var(--accent); text-decoration: underline; } .issue-labels { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; } .issue-body { line-height: 1.6; } diff --git a/templates/fragments.html b/templates/fragments.html index 4ed3cb1..defb15f 100644 --- a/templates/fragments.html +++ b/templates/fragments.html @@ -159,7 +159,7 @@
- #{{.ShortID}} · {{if .Open}}Open{{else}}Closed{{end}} · + · {{if .Open}}Open{{else}}Closed{{end}} · opened by {{if .Assignee}}· assigned to {{.Assignee}}{{end}} · {{.When}} diff --git a/templates/index.html b/templates/index.html index d7cdbcd..6fe52f2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -20,7 +20,7 @@ -->