Repositories, stored files, file collections, approval requests, agents, wiki proposals and addressed social threads are reached by path segments instead of query parameters. A person in a path is the member name when the relay has one, else the hex pubkey; name, npub and hex all resolve. The query keeps only filters, cursors and the branch. The old query forms are gone.
- commit
- 196be3813d45b7d852cc18e69856e0ca47cd76c3
- parent
- ddccb24bbf0bd23b4d8a3d573e2e22975351913c
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 21 | +++- |
| M | internal/ | 11 | + |
| M | internal/ | 22 | +++ |
| M | tinyclient/ | 8 | +- |
| M | tinyclient/ | 6 | +- |
| M | tinyclient/ | 10 | +- |
| M | tinyclient/ | 8 | +- |
| M | tinyclient/ | 2 | +- |
| M | tinyclient/ | 2 | +- |
| M | tinyclient/ | 7 | +- |
| M | tinyclient/ | 6 | +- |
| M | tinyclient/ | 2 | +- |
| M | tinyclient/ | 2 | +- |
| M | tinyclient/ | 4 | +- |
| M | tinyclient/ | 17 | +- |
| M | tinyclient/ | 8 | +- |
| M | tinyclient/ | 2 | +- |
| M | tinyclient/ | 4 | +- |
| M | tinyclient/ | 67 | +++++++-- |
| M | tinyclient/ | 4 | +- |
| M | tinyclient/ | 4 | +- |
| A | tinyclient/ | 229 | ++++++++++++++++++++++++++++++ |
| M | tinyclient/ | 14 | +- |
| M | tinyclient/ | 20 | +- |
| M | tinyclient/ | 2 | +- |
| M | tinyclient/ | 20 | ++- |
| M | tinyclient/ | 124 | +++++++++++----- |
| M | tinyclient/ | 28 | +++- |
| M | tinyclient/ | 4 | +- |
@@ -54,6 +54,25 @@ The **Account** page is the home for settings that belong to the signed-in key.
5454 5555 When a protected page sends you to **Sign in**, the sign-in link carries that page's tenant path and query. After the signer connects, the browser returns to the page you opened, including repository, file, wiki and approval context. Fragments are kept by the browser when client-side navigation supplies them.5656 57+## Addresses58+59+Every page that names one thing carries that name in its path, so the address stays the same for as long as the thing exists. A person appears by member name when the relay has one, and by public key otherwise. Both forms open the same page, and so does an npub.60+61+| Page | Address |62+| --- | --- |63+| Repository | `/repos/{owner}/{repo}` |64+| Files in a repository | `/repos/{owner}/{repo}/tree/{path}` and `/repos/{owner}/{repo}/file/{path}` |65+| History and one commit | `/repos/{owner}/{repo}/history` and `/repos/{owner}/{repo}/commit/{id}` |66+| Issues and pull requests | `/repos/{owner}/{repo}/issues/{id}` and `/repos/{owner}/{repo}/prs/{id}` |67+| Stored file | `/file/{hash}` |68+| File collections | `/files/sites`, `/files/rooms` and `/files/storage` |69+| Approval request | `/approvals/{id}` |70+| Agent | `/manage/agents/{agent}` |71+| Wiki proposal | `/wiki/{page}/proposals/{id}` |72+| Social thread | `/social/{id}` or `/social/{kind}:{author}:{name}` |73+74+A branch, a search, a page cursor and a folder inside a collection travel in the query, because they narrow a page rather than name it.75+5776 ## Copying a page address5877 59-On a phone, open **Menu** and choose **Copy page link**. On larger screens, tap the page name in the footer. The control says "copied" for a moment. The address is the relay's public URL with the page's canonical path, so a link copied while browsing over a tailnet or LAN address still opens for anyone: wiki names are normalized the way the relay resolves them, and repositories and files keep the query that names them.78+On a phone, open **Menu** and choose **Copy page link**. On larger screens, tap the page name in the footer. The control says "copied" for a moment. The address is the relay's public URL with the page's canonical path, so a link copied while browsing over a tailnet or LAN address still opens for anyone: wiki names are normalized the way the relay resolves them, and the query keeps only filters such as a search or a branch.@@ -39,6 +39,17 @@ func (s *Service) MemberByName(ctx context.Context, name string) (Member, error)
3939 return member, err4040 }4141 42+// MemberName returns the member name recorded for the pubkey, or "" when the43+// key is not a member or has no name.44+func (s *Service) MemberName(ctx context.Context, pubkey string) (string, error) {45+ var name string46+ err := s.store.DB().QueryRowContext(ctx, `SELECT name FROM community_members WHERE pubkey=?`, pubkey).Scan(&name)47+ if errors.Is(err, sql.ErrNoRows) {48+ return "", nil49+ }50+ return name, err51+}52+4253 func (s *Service) NIP05(ctx context.Context, name string, relayURL string) (map[string]any, error) {4354 member, err := s.MemberByName(ctx, name)4455 if err != nil {@@ -281,6 +281,28 @@ func (b backend) Identity() string { return b.tenant.records.PublicKey() }
281281 // languages, never a transform or a secret.282282 func (b backend) CustomViews() []views.View { return b.tenant.customViews.CustomViews() }283283 284+// NameFor and PubKeyFor let web pages write and read people by member name.285+func (b backend) NameFor(ctx context.Context, pubkey string) string {286+ if b.tenant.community == nil {287+ return ""288+ }289+ name, err := b.tenant.community.MemberName(ctx, pubkey)290+ if err != nil {291+ return ""292+ }293+ return name294+}295+func (b backend) PubKeyFor(ctx context.Context, name string) string {296+ if b.tenant.community == nil {297+ return ""298+ }299+ member, err := b.tenant.community.MemberByName(ctx, name)300+ if err != nil {301+ return ""302+ }303+ return member.PubKey304+}305+284306 // ReadAllowed is consumed by the web UI private boundary. It deliberately285307 // delegates to the same uncached membership check used by private Git and286308 // NIP-42, so a revoked browser session cannot retain page access.@@ -145,10 +145,10 @@ func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) {
145145 if !strings.Contains(string(backend.params[0]), agentActive) {146146 t.Fatalf("default selection should be the first active agent: %s", backend.params[0])147147 }148- // ?agent= selects whose activity shows.148+ // The agent segment selects whose activity shows.149149 backend.calls = nil150150 recorder = httptest.NewRecorder()151- app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents?agent="+agentPaused, nil))151+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents/"+agentPaused, nil))152152 if body := recorder.Body.String(); !strings.Contains(body, `<h3>Recent activity of reviewer-bot</h3>`) {153153 t.Fatalf("selected agent activity missing: %s", body[:min(500, len(body))])154154 }@@ -233,7 +233,7 @@ func TestAgentsPageEditPrefillsTheGrantForm(t *testing.T) {
233233 t.Fatal(err)234234 }235235 recorder := httptest.NewRecorder()236- app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents?agent="+agentActive+"&edit="+agentActive, nil))236+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents/"+agentActive+"?edit="+agentActive, nil))237237 body := recorder.Body.String()238238 for _, marker := range []string{239239 `<h3 id="grant">Replace the grant for hermes</h3>`,@@ -248,7 +248,7 @@ func TestAgentsPageEditPrefillsTheGrantForm(t *testing.T) {
248248 *</textarea>`,249249 `placeholder="1, 1111, 1621" value="9, 1111, 1621">`,250250 `<button data-primary>Sign the replacement</button>`,251- `&edit=` + agentActive + `#grant">edit</a>`,251+ `href="/manage/agents/` + agentActive + `?edit=` + agentActive + `#grant">edit</a>`,252252 } {253253 if !strings.Contains(body, marker) {254254 t.Errorf("edit form missing %q", marker)@@ -25,7 +25,7 @@ func (b *approvalsBackend) Query(_ context.Context, method string, params []json
2525 }2626 asker := strings.Repeat("c", 64)2727 open := map[string]any{"id": strings.Repeat("1", 64), "kind": 1111, "type": "approve", "asker": asker, "subject": "Publish release notes 1.4", "content": "Publish release notes 1.4 to the articles feed as drafted?", "created_at": 1788876000, "expires": 1788897600, "state": "open",28- "about": map[string]any{"coordinate": "30617:" + b.policy.Owner + ":tinyrelay", "event": strings.Repeat("d", 64), "kind": "1621", "url": "http://relay.example/repo?owner=" + b.policy.Owner + "&repo=tinyrelay&view=issue&id=" + strings.Repeat("d", 64)}}28+ "about": map[string]any{"coordinate": "30617:" + b.policy.Owner + ":tinyrelay", "event": strings.Repeat("d", 64), "kind": "1621", "url": "http://relay.example/repos/" + b.policy.Owner + "/tinyrelay/issues/" + strings.Repeat("d", 64)}}2929 question := map[string]any{"id": strings.Repeat("2", 64), "kind": 9, "type": "question", "asker": asker, "content": "Mention the edge?", "created_at": 1788876100, "state": "open", "room": "build"}3030 answered := map[string]any{"id": strings.Repeat("3", 64), "kind": 1111, "type": "decide", "asker": asker, "subject": "Close issue 41 as duplicate of 12", "content": "", "created_at": 1788870000, "state": "answered", "answer": map[string]any{"id": strings.Repeat("e", 64), "kind": 7, "author": b.policy.Owner, "decision": "approved", "content": "+", "created_at": 1788871000}}3131 expired := map[string]any{"id": strings.Repeat("4", 64), "kind": 1111, "type": "approve", "asker": asker, "content": "Create room #release", "created_at": 1788860000, "expires": 1788863600, "state": "expired"}@@ -67,7 +67,7 @@ func TestApprovalsPageRendersRequestsForTheOwner(t *testing.T) {
6767 "<pre>about 30617:" + backend.policy.Owner + ":tinyrelay | kind 1621 dddddddddddd</pre>",6868 `<nostr-react event="` + strings.Repeat("1", 64) + `" pubkey="` + asker + `" kind="1111"><button name="reaction" value="+" data-primary>Approve</button> <button name="reaction" value="-" data-danger>Deny</button></nostr-react>`,6969 `<nostr-compose kind="1111" root="` + strings.Repeat("1", 64) + `" root-pubkey="` + asker + `" root-kind="1111" coordinate="30617:` + backend.policy.Owner + `:tinyrelay">`,70- `<a href="/repo?owner=` + backend.policy.Owner + `&repo=tinyrelay&view=issue&id=` + strings.Repeat("d", 64) + `">open</a>`,70+ `<a href="/repos/` + backend.policy.Owner + `/tinyrelay/issues/` + strings.Repeat("d", 64) + `">open</a>`,7171 `<approval-item id="approval-` + strings.Repeat("2", 64) + `" data-type="question"`, "in #build", `<a href="/e/` + strings.Repeat("2", 64) + `">open</a>`,7272 "<h3>Answered</h3>", `<tr id="approval-` + strings.Repeat("3", 64) + `" data-state="answered">`, `<status-badge kind="approved">approved</status-badge> <time`, `data-state="expired"><td>`, `<td><status-badge kind="expired">expired</status-badge></td>`,7373 "<h4>Waiting</h4>", "<th>open</th><td>2</td>", "<th>oldest</th><td><time", "<h4>Devices</h4>", "<p><i></i>device 1 | approvals, mentions</p>", "<p><i></i>device 1 | ",@@ -94,7 +94,7 @@ func TestApprovalsPageRendersRequestsForTheOwner(t *testing.T) {
9494 // A notification opens one request; when it has left the first page it9595 // is read on its own and shown first.9696 recorder = httptest.NewRecorder()97- app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/approvals?id="+strings.Repeat("5", 64)+"&answer=approve", nil))97+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/approvals/"+strings.Repeat("5", 64)+"?answer=approve", nil))9898 body = recorder.Body.String()9999 if !strings.Contains(strings.Join(backend.calls, ","), "browseapprovals,browseapproval") {100100 t.Fatalf("calls %v", backend.calls)@@ -4,7 +4,7 @@
44 {{else if .Feed}}<table id="rows">55 <thead><tr><th>owner</th><th>repository</th><th>head</th></tr></thead>66 <tbody>7-{{range .Feed}}{{$r := asMap .}}<tr><td>{{template "nostrKey" (str (index $r "owner"))}}</td><td><a href="/repo?owner={{urlquery (str (index $r "owner"))}}&repo={{urlquery (str (index $r "identifier"))}}&view=home">{{str (index $r "identifier")}}</a>{{if index $r "private"}} <small>private</small>{{end}}</td><td>{{refName (index $r "head")}}</td></tr>7+{{range .Feed}}{{$r := asMap .}}<tr><td>{{template "nostrKey" (str (index $r "owner"))}}</td><td><a href="{{repoHref (or (str (index $r "handle")) (str (index $r "owner"))) (str (index $r "identifier")) "home" ""}}">{{str (index $r "identifier")}}</a>{{if index $r "private"}} <small>private</small>{{end}}</td><td>{{refName (index $r "head")}}</td></tr>88 {{end}}</tbody>99 </table>1010 {{else}}<p data-empty>No repositories were reported.</p>@@ -14,19 +14,19 @@
1414 1515 {{define "files"}}{{$data := asMap .Event}}{{$view := or (str (index $data "view")) (.Query.Get "view") "library"}}<section id="file-library" data-pubkey="{{.Actor}}">1616 <h1>Files</h1>17-{{if not (authRequired .Error)}}<nav id="file-views" aria-label="File collections"><a href="/files"{{if eq $view "library"}} aria-current="page"{{end}}>My files</a><a href="/files?view=sites"{{if eq $view "sites"}} aria-current="page"{{end}}>Sites</a><a href="/files?view=rooms"{{if eq $view "rooms"}} aria-current="page"{{end}}>Rooms</a>{{if index $data "can_manage"}}<a href="/files?view=storage"{{if eq $view "storage"}} aria-current="page"{{end}}>Storage</a>{{end}}</nav>17+{{if not (authRequired .Error)}}<nav id="file-views" aria-label="File collections"><a href="/files"{{if eq $view "library"}} aria-current="page"{{end}}>My files</a><a href="/files/sites"{{if eq $view "sites"}} aria-current="page"{{end}}>Sites</a><a href="/files/rooms"{{if eq $view "rooms"}} aria-current="page"{{end}}>Rooms</a>{{if index $data "can_manage"}}<a href="/files/storage"{{if eq $view "storage"}} aria-current="page"{{end}}>Storage</a>{{end}}</nav>1818 <p id="file-context">{{if eq $view "sites"}}Published assets, organized by site.{{else if eq $view "rooms"}}Attachments from rooms you can access.{{else if eq $view "storage"}}All stored files, newest first.{{end}}</p>19-<form method="get" action="/files" id="file-search"><input type="hidden" name="view" value="{{$view}}">{{with .Query.Get "path"}}<input type="hidden" name="path" value="{{.}}">{{end}}<label>Search <input name="q" value="{{.Query.Get "q"}}" placeholder="Name, type or hash" autocomplete="off"></label> <button>Search</button>{{if .Query.Get "q"}} <a href="{{browsePageURL "/files" .Query "q" ""}}">Clear</a>{{end}}</form>{{end}}19+<form method="get" action="{{.Path}}" id="file-search">{{with .Query.Get "path"}}<input type="hidden" name="path" value="{{.}}">{{end}}<label>Search <input name="q" value="{{.Query.Get "q"}}" placeholder="Name, type or hash" autocomplete="off"></label> <button>Search</button>{{if .Query.Get "q"}} <a href="{{browsePageURL .Path .Query "q" ""}}">Clear</a>{{end}}</form>{{end}}2020 {{with index $data "breadcrumbs"}}<nav id="file-breadcrumbs" aria-label="Folder path">{{range $i, $raw := .}}{{$crumb := asMap $raw}}{{if $i}} / {{end}}<a href="{{str (index $crumb "open")}}">{{str (index $crumb "name")}}</a>{{end}}</nav>{{end}}2121 {{if .Error}}{{template "alert" .Error}}2222 {{else if .Feed}}<table id="rows">2323 <thead><tr><th scope="col">Name</th><th scope="col">Details</th><th scope="col">Added</th></tr></thead>2424 <tbody>25-{{range .Feed}}{{$f := asMap .}}{{$kind := str (index $f "kind")}}{{$hash := str (index $f "sha256")}}{{$name := or (str (index $f "name")) (shortID $hash)}}{{$open := or (str (index $f "open")) (printf "/file?hash=%s" (urlquery $hash))}}<tr data-kind="{{$kind}}" data-hash="{{$hash}}"><td><a href="{{$open}}">{{if and $hash (hasPrefix (str (index $f "type")) "image/")}}<img loading="lazy" src="/files/raw?hash={{urlquery $hash}}" alt="">{{else}}<span data-file-icon aria-hidden="true">{{if or (eq $kind "folder") (eq $kind "site") (eq $kind "room")}}▱{{else}}▤{{end}}</span>{{end}}<span>{{$name}}</span></a>{{with index $f "context_url"}}<small><a href="{{.}}">{{str (index $f "context")}}</a></small>{{end}}</td><td>{{if eq $kind "folder"}}Folder{{else if eq $kind "site"}}Site{{else if eq $kind "room"}}Room{{else}}{{fileSize (index $f "size")}}<small>{{str (index $f "type")}}</small>{{if eq (str (index $f "access")) "members"}}<small>Relay members</small>{{else if eq (str (index $f "purpose")) "file"}}<small>Secret link</small>{{end}}{{end}}</td><td>{{with index $f "uploaded"}}<time datetime="{{datetime .}}">{{when .}}</time>{{end}}</td></tr>25+{{range .Feed}}{{$f := asMap .}}{{$kind := str (index $f "kind")}}{{$hash := str (index $f "sha256")}}{{$name := or (str (index $f "name")) (shortID $hash)}}{{$open := or (str (index $f "open")) (printf "/file/%s" $hash)}}<tr data-kind="{{$kind}}" data-hash="{{$hash}}"><td><a href="{{$open}}">{{if and $hash (hasPrefix (str (index $f "type")) "image/")}}<img loading="lazy" src="/files/raw?hash={{urlquery $hash}}" alt="">{{else}}<span data-file-icon aria-hidden="true">{{if or (eq $kind "folder") (eq $kind "site") (eq $kind "room")}}▱{{else}}▤{{end}}</span>{{end}}<span>{{$name}}</span></a>{{with index $f "context_url"}}<small><a href="{{.}}">{{str (index $f "context")}}</a></small>{{end}}</td><td>{{if eq $kind "folder"}}Folder{{else if eq $kind "site"}}Site{{else if eq $kind "room"}}Room{{else}}{{fileSize (index $f "size")}}<small>{{str (index $f "type")}}</small>{{if eq (str (index $f "access")) "members"}}<small>Relay members</small>{{else if eq (str (index $f "purpose")) "file"}}<small>Secret link</small>{{end}}{{end}}</td><td>{{with index $f "uploaded"}}<time datetime="{{datetime .}}">{{when .}}</time>{{end}}</td></tr>2626 {{end}}</tbody>2727 </table>2828 {{else}}<p id="file-empty">{{if .Query.Get "q"}}No files match this search.{{else if eq $view "sites"}}No site files here yet.{{else if eq $view "rooms"}}No room attachments here yet.{{else if .Query.Get "path"}}This folder is empty.{{else}}No files here yet. Use Upload to add files or a folder.{{end}}</p>29-{{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL "/files" $.Query "cursor" .}}">next page</a></p>29+{{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL $.Path $.Query "cursor" .}}">next page</a></p>3030 {{end}}</section>3131 {{end}}3232 @@ -46,9 +46,9 @@ func TestBrowseRoutesUseObjectContractsAndRenderData(t *testing.T) {
4646 path, method, marker string4747 }{4848 {"/repos", "browserepos", "notes"},49- {"/repo?owner=alice&repo=notes&view=file", "browserepo", "one"},49+ {"/repos/alice/notes/file/README", "browserepo", "one"},5050 {"/files", "browsefiles", "deadbeef"},51- {"/file?hash=deadbeef", "browsefile", "blob"},51+ {"/file/deadbeef", "browsefile", "blob"},5252 {"/manage/health", "browsestatus", "manage this relay"},5353 }5454 for _, test := range tests {@@ -103,7 +103,7 @@ func TestBrowseRendersTypedGitRelayPage(t *testing.T) {
103103 t.Fatal(err)104104 }105105 recorder := httptest.NewRecorder()106- app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/repo?owner=alice&repo=notes&view=file", nil))106+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/repos/alice/notes/file/README", nil))107107 body := recorder.Body.String()108108 if recorder.Code != http.StatusOK || !strings.Contains(body, "<script>") || !strings.Contains(body, "line two") || !strings.Contains(body, ">raw</a>") {109109 t.Fatalf("typed browse page status=%d body=%s", recorder.Code, body)@@ -117,7 +117,7 @@ func TestFileViewerEscapesHashAndExplainsEmptyFiles(t *testing.T) {
117117 t.Fatal(err)118118 }119119 recorder := httptest.NewRecorder()120- app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/file?hash=a%26b", nil))120+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/file/a%26b", nil))121121 body := recorder.Body.String()122122 if recorder.Code != http.StatusOK || !strings.Contains(body, `fx-ignore href="/files/raw?hash=a%26b"`) {123123 t.Fatalf("file hash was not URL encoded: status=%d body=%s", recorder.Code, body)@@ -2,7 +2,7 @@
22 {{define "chatActivityResponse"}}<div id="chat-activity-cards">{{template "chatActivityCards" .}}</div>{{end}}33 {{define "chatActivityCards"}}{{if .Error}}<p role="status">{{.Error}}</p>{{else}}{{range .Items}}<article id="activity-{{.ID}}" data-activity="{{.Type}}" data-state="{{.State}}" data-event="{{.ID}}">44 {{if eq .Type "approval"}}{{if ne .State "open"}}<details data-task="{{.ID}}"><summary><strong>{{.Title}}</strong> <small>{{if .Decision}}{{.Decision}}{{else}}{{.State}}{{end}}</small></summary>{{end}}<header><strong>{{if eq .Interaction "approval"}}Approval request{{else if eq .Interaction "confirmation"}}Confirmation{{else if .Question}}Question{{else}}Approval request{{end}}</strong> <small>{{if .Decision}}{{.Decision}}{{else}}{{.State}}{{end}}</small></header><p>{{.Title}}</p>{{if ne .Title .Content}}<details><summary>Details</summary><p>{{.Content}}</p></details>{{end}}5-<p><small>from {{template "nostrName" .Author}}</small> <a href="/approvals?id={{.ID}}">Open request</a></p>5+<p><small>from {{template "nostrName" .Author}}</small> <a href="/approvals/{{.ID}}">Open request</a></p>66 {{with .Answer}}<blockquote>{{.}}</blockquote>{{end}}{{if .CanDecide}}<chat-decision event="{{.ID}}" pubkey="{{.Author}}" kind="{{.Kind}}" room="{{.Room}}" actor="{{$.Actor}}" expires="{{.Expires}}" interaction="{{.Interaction}}" selection="{{if .Multiple}}multiple{{else if .Native}}{{if .Options}}single{{else}}text{{end}}{{end}}"{{if .Freeform}} freeform{{end}}{{if .Question}} question{{end}}{{if .Native}} native{{end}}{{if .Multiple}} multiple{{end}}>{{if .Native}}{{$multiple := .Multiple}}{{$freeform := .Freeform}}{{if .Options}}<fieldset><legend>{{if $multiple}}Choose one or more options{{else}}Choose an option{{end}}</legend>{{range .Options}}<label><input type="{{if $multiple}}checkbox{{else}}radio{{end}}" name="option" value="{{.ID}}"> {{.Label}}</label>{{end}}</fieldset>{{end}}{{if $freeform}}<label>{{if .Options}}Add details or write your own answer{{else}}Your answer{{end}} <textarea name="custom-answer" maxlength="8000" rows="2"></textarea></label>{{end}}<button type="submit" data-primary>Submit</button>{{else if .Question}}<label>Your answer <textarea name="content" required maxlength="8000" rows="2"></textarea></label><button type="submit" data-primary>Reply</button>{{else}}<button type="submit" name="reaction" value="+" data-primary>Approve</button><button type="submit" name="reaction" value="-" data-danger>Deny</button>{{end}}</chat-decision><noscript><p>Answering requires JavaScript and a connected signer.</p></noscript>{{end}}{{if ne .State "open"}}</details>{{end}}77 {{else}}<details id="activity-details-{{.ID}}" data-task="{{.ID}}"><summary><strong>{{.Title}}</strong> <small data-status="{{.Status}}">{{.Status}}</small></summary><p><small>{{template "nostrName" .Author}}</small></p><div>{{chatMarkdown .Content}}</div>{{with .Result}}{{if .Content}}<h4>Result</h4><div>{{chatMarkdown .Content}}</div>{{end}}<p><a href="/e/{{.ID}}">Open result</a></p>{{end}}<p><a href="/e/{{.ID}}">Open task</a></p></details>{{end}}88 </article>{{end}}{{end}}{{end}}@@ -40,7 +40,7 @@ func TestChatActivityEndpointScopesAndEscapesServerRenderedCards(t *testing.T) {
4040 if res.Code != 200 || res.Header().Get("Cache-Control") != "private, no-store" || b.seenActor != roomOwner || b.seenRoom != "general" || b.seenRoot != roomThread {4141 t.Fatalf("request context: %d %v %#v body=%s", res.Code, res.Header(), b, res.Body.String())4242 }43- for _, want := range []string{`<chat-decision`, `type="radio"`, `value="yes"`, `interaction="approval"`, `selection="single"`, `<strong>Approval request</strong>`, `href="/r/team/approvals?id=` + roomReply, `Approve <script>bad()</script>`, `<noscript>`} {43+ for _, want := range []string{`<chat-decision`, `type="radio"`, `value="yes"`, `interaction="approval"`, `selection="single"`, `<strong>Approval request</strong>`, `href="/r/team/approvals/` + roomReply, `Approve <script>bad()</script>`, `<noscript>`} {4444 if !strings.Contains(body, want) {4545 t.Errorf("missing %q in %s", want, body)4646 }@@ -1,8 +1,7 @@
11 {{define "collaboration"}}{{$view := repoView .Query}}{{$data := asMap .Event}}{{$coordinate := printf "30617:%s:%s" (.Query.Get "owner") (.Query.Get "repo")}}{{$issues := or (eq $view "issues") (eq $view "issue")}}22 {{if or (eq $view "issues") (eq $view "prs")}}<section id="collaboration-list">33 <h1>{{if $issues}}Issues{{else}}Pull requests{{end}}</h1>4-<form method="get" action="/repo" id="collaboration-filter">5-<input type="hidden" name="owner" value="{{.Query.Get "owner"}}"><input type="hidden" name="repo" value="{{.Query.Get "repo"}}"><input type="hidden" name="view" value="{{$view}}">4+<form method="get" action="{{repoPagePath .Query}}" id="collaboration-filter">65 {{$states := strs "open" "merged" "closed" "draft"}}{{if $issues}}{{$states = strs "open" "resolved" "closed" "draft"}}{{end}}<label>Status <select name="status"><option value="">all</option>{{range $state := $states}}<option value="{{$state}}"{{if eq ($.Query.Get "status") $state}} selected{{end}}>{{$state}}</option>{{end}}</select></label>76 <label>Search <input name="q" value="{{.Query.Get "q"}}"></label><label>Label <input name="label" value="{{.Query.Get "label"}}"></label><button>Filter</button>87 </form>@@ -10,7 +9,7 @@
109 <thead><tr><th>status</th><th>title</th><th>author</th><th>opened</th></tr></thead>1110 <tbody>{{range $item := $items}}{{$item := asMap $item}}{{$s := collabProposal $item $.Actor (index $data "can_approve")}}<tr{{if $s.Shown}} data-state="{{$s.State}}"{{end}}><td>{{index $item "status"}}</td><td><a href="{{if $issues}}{{repoURL $.Query "issue" (str (index $item "id"))}}{{else}}{{repoURL $.Query "pr" (str (index $item "id"))}}{{end}}">{{index $item "title"}}</a>{{if $s.Shown}} <small>proposed | {{$s.State}}</small>{{end}}{{with collabLabels $item}} <small>{{join . ", "}}</small>{{end}}</td><td>{{template "nostrName" (str (index $item "author"))}}</td><td><time datetime="{{datetime (index $item "created_at")}}">{{when (index $item "created_at")}}</time></td></tr>1211 {{end}}</tbody></table>{{else}}<p data-empty>No matching {{if $issues}}issues{{else}}pull requests{{end}}.</p>{{end}}13-{{with index $data "next_cursor"}}<p id="pages"><a href="{{browsePageURL "/repo" $.Query "cursor" .}}">next page</a></p>{{end}}12+{{with index $data "next_cursor"}}<p id="pages"><a href="{{repoPageURL $.Query "cursor" .}}">next page</a></p>{{end}}1413 {{if .Actor}}<details id="collaboration-new"><summary>{{if $issues}}New issue{{else}}New pull request{{end}}</summary>1514 <nostr-compose kind="{{if $issues}}1621{{else}}1618{{end}}" coordinate="{{$coordinate}}">1615 <label>Title <input name="title" required maxlength="300"></label><label>Labels <input name="labels" placeholder="bug, help wanted"></label>@@ -33,7 +32,7 @@
3332 {{range $reply := $replies}}{{$r := asMap $reply}}<li>{{template "collaborationProposal" (collabProposal $r $.Actor $can)}}{{template "nostrEvent" $reply}}{{with reviewLabel $reply}}<p><small>on {{.}}</small></p>{{end}}3433 {{if $.Actor}}<details><summary>Reply to this comment</summary><nostr-compose kind="1111" coordinate="{{$coordinate}}" root="{{index $item "id"}}" root-pubkey="{{index $item "author"}}" root-kind="{{index $item "kind"}}" parent="{{index $r "id"}}" parent-pubkey="{{index $r "pubkey"}}" parent-kind="1111"><label>Reply <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose></details>{{end}}</li>3534 {{end}}</ol>{{else}}<p data-empty>No replies yet.</p>{{end}}36-{{with index $data "reply_cursor"}}<p id="pages"><a href="{{browsePageURL "/repo" $.Query "cursor" .}}">older replies</a></p>{{end}}35+{{with index $data "reply_cursor"}}<p id="pages"><a href="{{repoPageURL $.Query "cursor" .}}">older replies</a></p>{{end}}3736 {{if .Actor}}<h2>Reply</h2>{{with $anchor}}<p id="reply-anchor">Commenting on <code>{{.File}}</code> line {{.Line}} ({{.Side}} side). <a href="{{repoURL $.Query "pr" (str (index $item "id"))}}">Comment on the whole change instead</a></p>{{end}}<nostr-compose id="reply-compose" kind="1111" coordinate="{{$coordinate}}" root="{{index $item "id"}}" root-pubkey="{{index $item "author"}}" root-kind="{{index $item "kind"}}">{{with $anchor}}<input type="hidden" name="file" value="{{.File}}"><input type="hidden" name="line" value="{{.Line}}"><input type="hidden" name="side" value="{{.Side}}">{{end}}<label>Comment <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose>3837 {{if index $data "can_status"}}<details id="status-controls"><summary>Change status</summary><nostr-compose kind="status" coordinate="{{$coordinate}}" root="{{index $item "id"}}" root-pubkey="{{index $item "author"}}" root-kind="{{index $item "kind"}}"><label>Status <select name="status"><option value="1630">Reopen</option><option value="1631">{{if $issues}}Resolve{{else}}Mark merged{{end}}</option><option value="1632">Close</option><option value="1633">Draft</option></select></label><label>Comment <textarea name="content"></textarea></label><button>Sign and update</button></nostr-compose></details>{{end}}3938 <noscript><p>JavaScript and a connected signer are required to publish.</p></noscript>{{else}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to reply.</p>{{end}}@@ -67,7 +67,7 @@ func TestRenderersShowCustomViewArtifactsWithTheCodeAsFallback(t *testing.T) {
6767 // README rendering maps relative links and keeps the figure.6868 query := url.Values{"owner": {"alice"}, "repo": {"notes"}, "ref": {"main"}}6969 repo := string(renderRepositoryMarkdownWith(source+"[guide](docs/guide.md)\n", query, renderer.Block))70- wantAll(t, "repository", repo, mermaidFigure("diagrams"), `href="/repo?owner=alice&path=docs%2Fguide.md&ref=main&repo=notes&view=file"`)70+ wantAll(t, "repository", repo, mermaidFigure("diagrams"), `href="/repos/alice/notes/file/docs/guide.md?ref=main"`)7171 // The wiki renderer takes the same renderer through its options.7272 article := string(wiki.RenderHTMLWith("# Title\n\n``` Mermaid\n"+mermaidSource+"\n```\n\n~~~\nplain\n~~~\n", wiki.Options{Block: renderer.Block}))7373 wantAll(t, "wiki", article, "<h1>Title</h1>", mermaidFigure("diagrams"), "<pre><code>plain\n</code></pre>")@@ -94,14 +94,14 @@ func TestPagesRenderCustomViewFiguresFromTheBackendSummary(t *testing.T) {
9494 // The repository home renders the README through the same renderer,9595 // and the tenant prefix reaches the object address.9696 recorder := httptest.NewRecorder()97- app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/repo?owner="+owner+"&repo=notes&view=home", nil))97+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/repos/"+owner+"/notes", nil))9898 body := recorder.Body.String()9999 if recorder.Code != http.StatusOK {100100 t.Fatalf("repo home: %d %s", recorder.Code, body)101101 }102102 hash := views.Hash("mermaid", "graph TD; a-->b;")103103 figure := `<figure data-view="diagrams"><view-artifact><img src="/views/diagrams/` + hash + `" alt="Rendered mermaid block"><details><summary>Source</summary><pre><code data-lang="mermaid">graph TD; a-->b;</code></pre></details></view-artifact></figure>`104- wantAll(t, "repo home", body, figure, `href="/repo?owner=`)104+ wantAll(t, "repo home", body, figure, `href="/repos/`)105105 if prefixed := injectBase(figure, "/r/work"); !strings.Contains(prefixed, `<img src="/r/work/views/diagrams/`+hash+`"`) {106106 t.Fatalf("tenant prefix missed the object address: %s", prefixed)107107 }@@ -38,7 +38,7 @@ func TestFilesRenderNamedFoldersAndPreserveBrowseQuery(t *testing.T) {
3838 if err != nil {3939 t.Fatal(err)4040 }41- for _, target := range []string{"/files", "/files?view=sites&path=Trip&q=snow&cursor=old"} {41+ for _, target := range []string{"/files", "/files/sites?path=Trip&q=snow&cursor=old"} {4242 response := httptest.NewRecorder()4343 app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, target, nil))4444 if response.Code != http.StatusOK {@@ -64,7 +64,7 @@
6464 {{with index $a "lastEvent"}}{{if when .}}<tr><th>last event</th><td><time datetime="{{datetime .}}">{{when .}}</time></td></tr>{{end}}{{end}}6565 <tr><th>callbacks</th><td>{{range $c := callbacksFor $.Callbacks $pk}}{{$cm := asMap $c}}{{$cs := callbackState $c}}{{$cid := str (index $cm "id")}}<agent-callback data-state="{{$cs}}"><code>{{str (index $cm "host")}}</code> kinds {{callbackKinds $c}} | <span data-state="{{$cs}}" title="{{str (index $cm "lastStatus")}}">{{$cs}}</span>{{with index $cm "lastDelivery"}}{{if when .}} | delivered <time datetime="{{datetime .}}">{{when .}}</time>{{end}}{{end}} {{if eq $cs "paused"}}<rpc-form method="resumecallback" refresh><input type="hidden" name="param" value=""{{$cid}}""><button>Resume</button></rpc-form>{{else}}<rpc-form method="pausecallback" refresh><input type="hidden" name="param" value=""{{$cid}}""><button>Pause</button></rpc-form>{{end}}<rpc-form method="removecallback" refresh><input type="hidden" name="param" value=""{{$cid}}""><button>Remove</button></rpc-form></agent-callback>{{else}}none{{end}}</td></tr>6666 </table>67-<p>{{if eq $state "paused"}}<rpc-form method="resumeagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Resume</button></rpc-form>{{else if eq $state "active"}}<rpc-form method="pauseagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Pause</button></rpc-form>{{end}}{{if ne $state "revoked"}}<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button data-danger>Revoke</button></rpc-form>{{end}}<a href="/manage/agents?agent={{$pk}}">recent activity</a>{{if ne $state "revoked"}} <a href="/manage/agents?agent={{$pk}}&edit={{$pk}}#grant">edit</a>{{end}}</p>67+<p>{{if eq $state "paused"}}<rpc-form method="resumeagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Resume</button></rpc-form>{{else if eq $state "active"}}<rpc-form method="pauseagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Pause</button></rpc-form>{{end}}{{if ne $state "revoked"}}<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button data-danger>Revoke</button></rpc-form>{{end}}<a href="/manage/agents/{{or (str (index $a "handle")) $pk}}">recent activity</a>{{if ne $state "revoked"}} <a href="/manage/agents/{{or (str (index $a "handle")) $pk}}?edit={{$pk}}#grant">edit</a>{{end}}</p>6868 </agent-card>6969 {{end}}{{with asMap .Event}}{{$agent := asMap (index . "agent")}}7070 <h3>Recent activity of {{agentLabel $agent}}</h3>@@ -21,7 +21,7 @@ func TestMarkdownRendersCommonFormsAndEscapesTheRest(t *testing.T) {
2121 func TestRepositoryMarkdownResolvesRelativeLinks(t *testing.T) {2222 query := url.Values{"owner": {"alice"}, "repo": {"notes"}, "ref": {"refs/heads/main"}}2323 got := string(renderRepositoryMarkdown("[guide](docs/guide.md) [up](../secret.md) [site](https://example.com)", query))24- if !strings.Contains(got, `href="/repo?owner=alice&path=docs%2Fguide.md&ref=refs%2Fheads%2Fmain&repo=notes&view=file"`) {24+ if !strings.Contains(got, `href="/repos/alice/notes/file/docs/guide.md?ref=refs%2Fheads%2Fmain"`) {2525 t.Fatalf("relative link was not mapped to repository viewer: %s", got)2626 }2727 if !strings.Contains(got, `href="https://example.com"`) {@@ -32,7 +32,7 @@ func TestRepositoryMarkdownResolvesRelativeLinks(t *testing.T) {
3232 func TestRepositoryMarkdownDecodesPathsAndPreservesFragments(t *testing.T) {3333 query := url.Values{"owner": {"alice"}, "repo": {"notes"}, "ref": {"refs/heads/main"}}3434 got := string(renderRepositoryMarkdown("[guide](docs/caf%C3%A9%20guide.md?download=1#L2)", query))35- want := `href="/repo?download=1&owner=alice&path=docs%2Fcaf%C3%A9+guide.md&ref=refs%2Fheads%2Fmain&repo=notes&view=file#L2"`35+ want := `href="/repos/alice/notes/file/docs/caf%C3%A9%20guide.md?download=1&ref=refs%2Fheads%2Fmain#L2"`3636 if !strings.Contains(got, want) {3737 t.Fatalf("encoded README link lost its resource or fragment: %s", got)3838 }@@ -143,12 +143,9 @@ func generalMobileHeader(data PageData) mobileHeaderData {
143143 result.BackLabel = "Back to manage"144144 case path == "/sites":145145 result.BackURL = "/"146- case path == "/file":146+ case strings.HasPrefix(path, "/file/"):147147 result.BackURL = "/files"148148 result.BackLabel = "Back to files"149- case path == "/repo":150- result.BackURL = "/repos"151- result.BackLabel = "Back to repositories"152149 case path == "/profile":153150 result.BackURL = "/account"154151 result.BackLabel = "Back to account"@@ -220,7 +217,7 @@ func shortHeaderText(value string) string {
220217 }221218 222219 func mobileTitle(path string, data PageData) string {223- known := map[string]string{"/": "Home", "/home": "Home", "/search": "Search", "/social": "Social", "/chat": "Chat", "/files": "Files", "/file": "File", "/wiki": "Wiki", "/repos": "Repositories", "/repo": "Repository", "/approvals": "Approvals", "/profile": "Profile", "/account": "Account", "/manage": "Manage", "/manage/people": "People", "/manage/agents": "Agents", "/manage/moderation": "Moderation", "/manage/rules": "Rules", "/manage/identity": "Identity", "/manage/connect": "Connect", "/manage/owner": "Owner", "/manage/sync": "Sync", "/manage/data": "Data", "/manage/views": "Views", "/manage/health": "Health", "/sites": "Sites"}220+ known := map[string]string{"/": "Home", "/home": "Home", "/search": "Search", "/social": "Social", "/chat": "Chat", "/files": "Files", "/wiki": "Wiki", "/repos": "Repositories", "/approvals": "Approvals", "/profile": "Profile", "/account": "Account", "/manage": "Manage", "/manage/people": "People", "/manage/agents": "Agents", "/manage/moderation": "Moderation", "/manage/rules": "Rules", "/manage/identity": "Identity", "/manage/connect": "Connect", "/manage/owner": "Owner", "/manage/sync": "Sync", "/manage/data": "Data", "/manage/views": "Views", "/manage/health": "Health", "/sites": "Sites"}224221 if title, ok := known[path]; ok {225222 return title226223 }@@ -233,8 +230,14 @@ func mobileTitle(path string, data PageData) string {
233230 if strings.HasPrefix(path, "/wiki/") {234231 return "Wiki"235232 }236- if strings.HasPrefix(path, "/repos/") {237- return "Repositories"233+ if strings.HasPrefix(path, "/file/") {234+ return "File"235+ }236+ if parseRepoRoute(path).ok {237+ return "Repository"238+ }239+ if strings.HasPrefix(path, "/files/") || strings.HasPrefix(path, "/approvals/") {240+ return known["/"+strings.SplitN(strings.TrimPrefix(path, "/"), "/", 2)[0]]238241 }239242 if title := strings.TrimSuffix(data.Title, " | "+data.Slug); title != "" && !eventIDPattern.MatchString(title) {240243 return title@@ -17,8 +17,8 @@ func TestMobileHeaderGeneralRoutes(t *testing.T) {
1717 {"/repos", "Repositories | tiny", "Repositories", "tiny", "/"},1818 {"/manage/people", "people | tiny", "People", "Manage", "/"},1919 {"/social/thread/abc", "Social | tiny", "Social", "tiny", "/social"},20- {"/file", "File | tiny", "File", "tiny", "/files"},21- {"/repo", "Repository | tiny", "Repository", "tiny", "/repos"},20+ {"/file/9999999999999999999999999999999999999999999999999999999999999999", "File | tiny", "File", "tiny", "/files"},21+ {"/repos/alice/notes", "Repository | tiny", "Repository", "tiny", "/repos"},2222 {"/profile", "Profile | tiny", "Profile", "tiny", "/account"},2323 {"/sites", "sites | tiny", "Sites", "tiny", "/"},2424 }@@ -88,11 +88,11 @@ func TestMobileHeaderUsesDeepLinkContext(t *testing.T) {
8888 if article.Title != "A useful article" || article.Subtitle != "Social" || article.BackURL != "/social" || article.BackLabel != "Back to social" {8989 t.Fatalf("article header = %+v", article)9090 }91- file := mobileHeader(PageData{Path: "/file", Tab: "file", Slug: "tiny", Event: map[string]any{"name": "photo.mp4"}})91+ file := mobileHeader(PageData{Path: "/file/9999999999999999999999999999999999999999999999999999999999999999", Tab: "file", Slug: "tiny", Event: map[string]any{"name": "photo.mp4"}})9292 if file.Title != "photo.mp4" || file.Subtitle != "Files" || file.BackURL != "/files" || file.BackLabel != "Back to files" {9393 t.Fatalf("file header = %+v", file)9494 }95- repo := mobileHeader(PageData{Path: "/repo", Tab: "repo", Slug: "tiny", Query: url.Values{"repo": []string{"seedmark"}, "view": []string{"issues"}}})95+ repo := mobileHeader(PageData{Path: "/repos/alice/seedmark", Tab: "repo", Slug: "tiny", Query: url.Values{"repo": []string{"seedmark"}, "view": []string{"issues"}}})9696 if repo.Title != "seedmark" || repo.Subtitle != "issues" || repo.BackURL != "/repos" || repo.BackLabel != "Back to repositories" {9797 t.Fatalf("repo header = %+v", repo)9898 }@@ -172,7 +172,7 @@ then feature modules are loaded for the current page. */}}
172172 {{define "panel-repo"}}{{$r := asMap .Event}}{{with index $r "repository"}}{{$r = asMap .}}{{end}}<section><h4>About</h4><table><tr><th>owner</th><td>{{template "nostrKey" (str (index $r "owner"))}}</td></tr><tr><th>visibility</th><td>{{if index $r "private"}}private{{else}}public{{end}}</td></tr>{{with index $r "commit"}}<tr><th>head</th><td><code title="{{.}}">{{shortID .}}</code></td></tr>{{end}}</table></section>173173 {{with index $r "maintainers"}}<section><h4>Maintainers</h4><ul id="maintainers">{{range .}}{{$m := asMap .}}<li>{{template "nostrName" (str (index $m "pubkey"))}}{{$role := str (index $m "role")}}{{if eq $role "agent"}} <small>agent{{with str (index $m "name")}} {{.}}{{end}}</small>{{else if eq $role "owner"}} <small>owner</small>{{end}}</li>{{end}}</ul></section>174174 {{end}}<section><h4>Clone</h4><ul>{{range index $r "clone"}}<li><code>{{.}}</code></li>{{else}}<li>no clone URL advertised</li>{{end}}</ul></section>175-{{with index $r "refs"}}<section><h4>Refs</h4><form method="get" action="/repo"><input type="hidden" name="owner" value="{{$.Query.Get "owner"}}"><input type="hidden" name="repo" value="{{$.Query.Get "repo"}}"><input type="hidden" name="path" value="{{$.Query.Get "path"}}"><input type="hidden" name="id" value="{{$.Query.Get "id"}}"><input type="hidden" name="view" value="{{repoView $.Query}}"><label for="ref-select">branch or tag</label><select id="ref-select" name="ref"><option value="">default branch</option>{{range $name, $oid := .}}<option value="{{$name}}"{{if eq ($.Query.Get "ref") $name}} selected{{end}}>{{$name}}</option>{{end}}</select><button>Go</button></form></section>{{end}}{{end}}175+{{with index $r "refs"}}<section><h4>Refs</h4><form method="get" action="{{repoPagePath $.Query}}"><label for="ref-select">branch or tag</label><select id="ref-select" name="ref"><option value="">default branch</option>{{range $name, $oid := .}}<option value="{{$name}}"{{if eq ($.Query.Get "ref") $name}} selected{{end}}>{{$name}}</option>{{end}}</select><button>Go</button></form></section>{{end}}{{end}}176176 177177 {{define "panel-manage"}}<section><h4>Policy</h4><table><tr><th>name</th><td>{{.Policy.Name}}</td></tr><tr><th>reads</th><td>{{.Policy.Reads}}</td></tr><tr><th>writes</th><td>{{.Policy.Writes}}</td></tr><tr><th>owner</th><td>{{template "nostrKey" .Policy.Owner}}</td></tr></table></section>178178 {{if .Review}}<section><h4>People</h4><table><tr><th>requests</th><td>{{len (pendingRequests .Requests)}} pending</td></tr></table></section>@@ -16,7 +16,7 @@
1616 {{else}}<tr><td colspan="3">No commits.</td></tr>1717 {{end}}</tbody>1818 </table>19-<p id="pages">{{if .Query.Get "offset"}}<a href="{{repoURL .Query "history" ""}}">first page</a> {{end}}{{if hasNextOffset (index $repo "next_offset")}}<a href="{{browsePageURL "/repo" .Query "offset" (index $repo "next_offset")}}">next page</a>{{end}}</p>19+<p id="pages">{{if .Query.Get "offset"}}<a href="{{repoURL .Query "history" ""}}">first page</a> {{end}}{{if hasNextOffset (index $repo "next_offset")}}<a href="{{repoPageURL .Query "offset" (index $repo "next_offset")}}">next page</a>{{end}}</p>2020 </section>2121 {{else if eq $view "commit"}}<section>2222 <h1>Commit <code>{{shortID (index $repo "commit")}}</code></h1>@@ -36,5 +36,5 @@
3636 {{range $e := $entries}}{{$entry := asMap $e}}<li>{{if eq (str (index $entry "type")) "tree"}}<a href="{{repoURL $.Query "tree" (str (index $entry "path"))}}">{{str (index $entry "name")}}/</a>{{else if eq (str (index $entry "type")) "commit"}}{{str (index $entry "name")}} <span>(submodule)</span>{{else}}<a href="{{repoURL $.Query "file" (str (index $entry "path"))}}"{{if eq (str (index $entry "path")) ($.Query.Get "path")}} aria-current="page"{{end}}>{{str (index $entry "name")}}</a>{{end}}</li>3737 {{else}}<li><span>no published files here</span></li>3838 {{end}}</ul>39-<p id="pages">{{if .Query.Get "offset"}}<a href="{{repoURL .Query "tree" (.Query.Get "path")}}">first page</a> {{end}}{{if hasNextOffset (index $repo "next_offset")}}<a href="{{browsePageURL "/repo" .Query "offset" (index $repo "next_offset")}}">more</a>{{end}}</p>39+<p id="pages">{{if .Query.Get "offset"}}<a href="{{repoURL .Query "tree" (.Query.Get "path")}}">first page</a> {{end}}{{if hasNextOffset (index $repo "next_offset")}}<a href="{{repoPageURL .Query "offset" (index $repo "next_offset")}}">more</a>{{end}}</p>4040 {{end}}@@ -9,21 +9,53 @@ import (
99 1010 type repoCrumb struct{ Label, URL string }1111 12-func repoURL(query url.Values, view, path string) string {13- values := url.Values{"owner": {query.Get("owner")}, "repo": {query.Get("repo")}, "ref": {query.Get("ref")}, "view": {view}}14- if path != "" {15- if view == "issue" || view == "pr" {16- values.Set("id", path)17- } else {18- values.Set("path", path)19- }12+// repoHandle is the owner segment for links from the current page: the13+// resolved handle when the page carries one, else the owner as given.14+func repoHandle(query url.Values) string {15+ if handle := query.Get("handle"); handle != "" {16+ return handle2017 }21- return "/repo?" + values.Encode()18+ return query.Get("owner")19+}20+21+// repoURL links a view of the current repository, keeping the branch and22+// nothing else from the current query.23+func repoURL(query url.Values, view, item string) string {24+ keep := url.Values{}25+ if ref := query.Get("ref"); ref != "" && view != "commit" {26+ keep.Set("ref", ref)27+ }28+ return repoPath(repoHandle(query), query.Get("repo"), view, item, keep)2229 }2330 2431 func repoCommitURL(query url.Values, oid string) string {25- values := url.Values{"owner": {query.Get("owner")}, "repo": {query.Get("repo")}, "ref": {oid}}26- return repoURL(values, "commit", "")32+ return repoPath(repoHandle(query), query.Get("repo"), "commit", oid, nil)33+}34+35+// repoPagePath is the current repository page without its query, the36+// action for forms that rebuild the query from their fields.37+func repoPagePath(query url.Values) string {38+ item := query.Get("path")39+ if view := repoView(query); view == "issue" || view == "pr" || view == "commit" {40+ item = query.Get("id")41+ }42+ return repoPath(repoHandle(query), query.Get("repo"), repoView(query), item, nil)43+}44+45+// repoPageURL is the current repository page with one query value changed.46+func repoPageURL(query url.Values, field string, value any) string {47+ values := transientQuery(query)48+ values.Set(field, fmt.Sprint(value))49+ item := query.Get("path")50+ if view := repoView(query); view == "issue" || view == "pr" || view == "commit" {51+ item = query.Get("id")52+ }53+ return repoPath(repoHandle(query), query.Get("repo"), repoView(query), item, values)54+}55+56+// repoHref links a repository by its owner handle, for lists.57+func repoHref(handle, repo, view, item string) string {58+ return repoPath(handle, repo, view, item, nil)2759 }2860 2961 func hasNextOffset(value any) bool {@@ -50,12 +82,17 @@ func repoBreadcrumbs(query url.Values) []repoCrumb {
5082 return crumbs5183 }5284 85+// browsePageURL is a list page with one query value changed. Values the86+// page derives from its path are not written back.5387 func browsePageURL(route string, query url.Values, field string, value any) string {54- values := make(url.Values, len(query)+1)55- for name, items := range query {56- values[name] = append([]string(nil), items...)57- }88+ values := transientQuery(query)5889 values.Set(field, fmt.Sprint(value))90+ if value == "" || value == nil {91+ values.Del(field)92+ }93+ if len(values) == 0 {94+ return route95+ }5996 return route + "?" + values.Encode()6097 }6198 @@ -106,7 +106,7 @@ func TestPullRequestPagePlacesAnchoredCommentsUnderDiffLines(t *testing.T) {
106106 t.Fatal("compose was preset without a query anchor")107107 }108108 w = httptest.NewRecorder()109- app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, link+"&file=README&line=2&side=new", nil))109+ app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, link+"?file=README&line=2&side=new", nil))110110 body = w.Body.String()111111 for _, want := range []string{`<nostr-compose id="reply-compose" kind="1111"`, `<input type="hidden" name="file" value="README">`, `<input type="hidden" name="line" value="2">`, `<input type="hidden" name="side" value="new">`, `<p id="reply-anchor">Commenting on <code>README</code> line 2 (new side).`} {112112 if !strings.Contains(body, want) {@@ -114,7 +114,7 @@ func TestPullRequestPagePlacesAnchoredCommentsUnderDiffLines(t *testing.T) {
114114 }115115 }116116 w = httptest.NewRecorder()117- app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, link+"&file=README&line=0&side=left", nil))117+ app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, link+"?file=README&line=0&side=left", nil))118118 if strings.Contains(w.Body.String(), `name="file"`) {119119 t.Fatal("an invalid query anchor preset the compose")120120 }@@ -43,8 +43,8 @@ func (a *App) handleBrowseRoute(writer http.ResponseWriter, request *http.Reques
4343 }4444 4545 func isBrowsePath(path string) bool {46- return path == "/repos" || path == "/repo" || path == "/files" || path == "/file" ||47- path == "/approvals" || path == "/profile" || path == "/wiki" ||46+ return path == "/repos" || parseRepoRoute(path).ok || path == "/files" || filesView(path) != "" || fileHash(path) != "" ||47+ path == "/approvals" || approvalID(path) != "" || path == "/profile" || path == "/wiki" ||4848 strings.HasPrefix(path, "/wiki/") || path == "/manage/health" || roomRoute(path).tab != ""4949 }5050 @@ -0,0 +1,229 @@
1+package tinyclient2+3+import (4+ "context"5+ "encoding/hex"6+ "net/url"7+ "strings"8+)9+10+// NameDirectory resolves relay member names for URLs. It is optional; a11+// backend without it writes and reads hex pubkeys only.12+type NameDirectory interface {13+ // NameFor returns the member name for a pubkey, or "" when it has none.14+ NameFor(ctx context.Context, pubkey string) string15+ // PubKeyFor returns the pubkey behind a member name, or "" when unknown.16+ PubKeyFor(ctx context.Context, name string) string17+}18+19+// person turns a URL segment into a pubkey. It accepts a member name, an20+// npub and a hex pubkey. An unknown name resolves to "".21+func (a *App) person(ctx context.Context, segment string) string {22+ segment = strings.TrimSpace(segment)23+ if segment == "" {24+ return ""25+ }26+ if eventIDPattern.MatchString(segment) {27+ return strings.ToLower(segment)28+ }29+ if strings.HasPrefix(segment, "npub1") {30+ if hrp, data, ok := bech32Decode(segment); ok && hrp == "npub" && len(data) == 32 {31+ return hex.EncodeToString(data)32+ }33+ return ""34+ }35+ if directory, ok := a.backend.(NameDirectory); ok {36+ return directory.PubKeyFor(ctx, segment)37+ }38+ // Without a directory the segment is passed through as written; the39+ // backend decides whether it names anyone.40+ return segment41+}42+43+// handle is the segment a pubkey is written as: the member name when the44+// relay knows one, else the hex pubkey.45+func (a *App) handle(ctx context.Context, pubkey string) string {46+ if directory, ok := a.backend.(NameDirectory); ok && pubkey != "" {47+ if name := directory.NameFor(ctx, pubkey); name != "" {48+ return name49+ }50+ }51+ return pubkey52+}53+54+// repoRoute is one parsed /repos/{owner}/{repo}/... path. Segments stay as55+// written; the owner is resolved to a pubkey by the caller.56+type repoRoute struct {57+ owner, repo, view, path, id string58+ ok bool59+}60+61+var repoViews = map[string]bool{"tree": true, "file": true, "history": true, "commit": true, "activity": true, "issues": true, "prs": true}62+63+func parseRepoRoute(path string) repoRoute {64+ rest, ok := strings.CutPrefix(path, "/repos/")65+ if !ok || rest == "" {66+ return repoRoute{}67+ }68+ parts := strings.SplitN(rest, "/", 4)69+ if len(parts) < 2 || parts[0] == "" || parts[1] == "" {70+ return repoRoute{}71+ }72+ route := repoRoute{owner: unescapeSegment(parts[0]), repo: unescapeSegment(parts[1]), view: "home", ok: true}73+ if len(parts) == 2 {74+ return route75+ }76+ view := parts[2]77+ if !repoViews[view] {78+ return repoRoute{}79+ }80+ route.view = view81+ tail := ""82+ if len(parts) == 4 {83+ tail = parts[3]84+ }85+ switch view {86+ case "tree":87+ route.path = unescapePath(tail)88+ case "file":89+ if tail == "" {90+ return repoRoute{}91+ }92+ route.path = unescapePath(tail)93+ case "commit":94+ if tail == "" {95+ return repoRoute{}96+ }97+ route.id = unescapeSegment(tail)98+ case "issues", "prs":99+ if tail != "" {100+ if !eventIDPattern.MatchString(tail) {101+ return repoRoute{}102+ }103+ route.id = tail104+ route.view = map[string]string{"issues": "issue", "prs": "pr"}[view]105+ }106+ default:107+ if tail != "" {108+ return repoRoute{}109+ }110+ }111+ return route112+}113+114+func unescapeSegment(value string) string {115+ if decoded, err := url.PathUnescape(value); err == nil {116+ return decoded117+ }118+ return value119+}120+121+func unescapePath(value string) string {122+ parts := strings.Split(value, "/")123+ for i, part := range parts {124+ parts[i] = unescapeSegment(part)125+ }126+ return strings.Join(parts, "/")127+}128+129+func escapePath(value string) string {130+ parts := strings.Split(value, "/")131+ for i, part := range parts {132+ parts[i] = url.PathEscape(part)133+ }134+ return strings.Join(parts, "/")135+}136+137+// repoPath writes a repository page address. owner is the handle to show,138+// view one of the page views (issue and pr mean one item named by id), and139+// path or id the item within the view. Query keeps only transient state.140+func repoPath(owner, repo, view, item string, query url.Values) string {141+ base := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo)142+ switch view {143+ case "", "home":144+ case "tree":145+ base += "/tree"146+ if item != "" {147+ base += "/" + escapePath(item)148+ }149+ case "file":150+ base += "/file/" + escapePath(item)151+ case "commit":152+ base += "/commit/" + url.PathEscape(item)153+ case "issue":154+ base += "/issues/" + url.PathEscape(item)155+ case "pr":156+ base += "/prs/" + url.PathEscape(item)157+ default:158+ base += "/" + view159+ }160+ keep := url.Values{}161+ for _, key := range []string{"ref", "offset", "cursor", "q", "status", "label", "download"} {162+ if value := query.Get(key); value != "" {163+ keep.Set(key, value)164+ }165+ }166+ if len(keep) == 0 {167+ return base168+ }169+ return base + "?" + keep.Encode()170+}171+172+// routeKeys are query values the page derives from its path. Links built173+// from the current query drop them so they are never written back out.174+var routeKeys = map[string]bool{"owner": true, "handle": true, "repo": true, "view": true, "id": true, "hash": true, "agent": true, "merge": true, "d": true, "address": true}175+176+func transientQuery(query url.Values) url.Values {177+ values := make(url.Values, len(query))178+ for name, items := range query {179+ if routeKeys[name] {180+ continue181+ }182+ values[name] = append([]string(nil), items...)183+ }184+ return values185+}186+187+// filesView names the collection behind /files/{view}, or "" when the path188+// is not one.189+func filesView(path string) string {190+ view, ok := strings.CutPrefix(path, "/files/")191+ if !ok {192+ return ""193+ }194+ switch view {195+ case "sites", "rooms", "storage":196+ return view197+ }198+ return ""199+}200+201+// fileHash is the hash behind /file/{hash}, or "".202+func fileHash(path string) string {203+ hash, ok := strings.CutPrefix(path, "/file/")204+ if !ok || hash == "" || strings.Contains(hash, "/") {205+ return ""206+ }207+ return unescapeSegment(hash)208+}209+210+// approvalID is the request behind /approvals/{id}, or "".211+func approvalID(path string) string {212+ id, ok := strings.CutPrefix(path, "/approvals/")213+ if !ok || !eventIDPattern.MatchString(id) {214+ return ""215+ }216+ return strings.ToLower(id)217+}218+219+// addHandles writes a "handle" beside the pubkey field of each row so220+// templates can link people by name.221+func (a *App) addHandles(ctx context.Context, rows []any, field string) {222+ for _, row := range rows {223+ if item, _ := row.(map[string]any); item != nil {224+ if pubkey := plainString(item[field]); pubkey != "" {225+ item["handle"] = a.handle(ctx, pubkey)226+ }227+ }228+ }229+}@@ -34,9 +34,9 @@ func TestShellRendersRailPanelAndPrompt(t *testing.T) {
3434 t.Fatal(err)3535 }3636 for path, wants := range map[string][]string{37- "/": {`id="mark"`, `href="/search"`, `<b>demo</b> » <page-link url="http://relay.example" title="Copy the page address">home</page-link>`, `id="panel"`, `id="topbar"`, `rel="manifest"`},38- "/manage/rules": {`<b>manage</b>`, `href="/manage/rules" aria-current="page"`, `manage/rules</page-link></span>`},39- "/repo?owner=aa&repo=notes&view=history": {`/history</a>`, `repos/notes/history`},37+ "/": {`id="mark"`, `href="/search"`, `<b>demo</b> » <page-link url="http://relay.example" title="Copy the page address">home</page-link>`, `id="panel"`, `id="topbar"`, `rel="manifest"`},38+ "/manage/rules": {`<b>manage</b>`, `href="/manage/rules" aria-current="page"`, `manage/rules</page-link></span>`},39+ "/repos/aa/notes/history": {`/history</a>`, `repos/notes/history`},4040 } {4141 recorder := httptest.NewRecorder()4242 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))@@ -73,7 +73,7 @@ func TestShellNavigationIsProgressiveAndAccessible(t *testing.T) {
7373 7474 // The menu is part of the shell, so each viewer and rail context retains7575 // one native control and one shared navigation container.76- for _, path := range []string{"/search", "/social", "/files", "/repo?owner=alice&repo=notes&view=history", "/e/" + strings.Repeat("1", 64), "/manage/rules"} {76+ for _, path := range []string{"/search", "/social", "/files", "/repos/alice/notes/history", "/e/" + strings.Repeat("1", 64), "/manage/rules"} {7777 recorder := httptest.NewRecorder()7878 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))7979 if recorder.Code != http.StatusOK {@@ -176,7 +176,7 @@ func TestRepositoryHomeRendersReadmeAsMarkdown(t *testing.T) {
176176 t.Fatal(err)177177 }178178 recorder := httptest.NewRecorder()179- app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/repo?owner=alice&repo=notes&view=home", nil))179+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/repos/alice/notes", nil))180180 body := recorder.Body.String()181181 if recorder.Code != http.StatusOK || !strings.Contains(body, `id="readme"`) || !strings.Contains(body, "<p>one two</p>") {182182 t.Fatalf("repository home did not render the README: %d %s", recorder.Code, body)@@ -264,7 +264,7 @@ func TestPromptPathSpellsOutRepositoriesAndFiles(t *testing.T) {
264264 if got := promptPath("/", nil); got != "home" {265265 t.Fatalf("home prompt = %q", got)266266 }267- if got := promptPath("/repo", url.Values{"repo": {"notes"}, "view": {"file"}}); got != "repos/notes/file" {267+ if got := promptPath("/repos/alice/notes/file/README", url.Values{"repo": {"notes"}, "view": {"file"}}); got != "repos/notes/file" {268268 t.Fatalf("repo prompt = %q", got)269269 }270270 if got := promptPath("/manage/people", nil); got != "manage/people" {@@ -287,7 +287,7 @@ func TestFilePagePreviewsImages(t *testing.T) {
287287 t.Fatal(err)288288 }289289 recorder := httptest.NewRecorder()290- app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/file?sha="+strings.Repeat("9", 64), nil))290+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/file/"+strings.Repeat("9", 64), nil))291291 if body := recorder.Body.String(); !strings.Contains(body, `<img src="/files/raw?hash=`+strings.Repeat("9", 64)+`"`) {292292 t.Fatalf("no image preview: %s", body)293293 }@@ -71,10 +71,12 @@ func (a *App) socialPage(w http.ResponseWriter, r *http.Request) {
7171 actor, _ := a.resolveActor(r)7272 params := socialParameters(r.URL.Query())7373 method, tab := "browsesocial", "social"74- if strings.HasPrefix(r.URL.Path, "/social/") {75- params["id"] = strings.TrimPrefix(r.URL.Path, "/social/")76- method, tab = "browsesocialthread", "social-thread"77- } else if r.URL.Query().Get("address") != "" {74+ if rest, ok := strings.CutPrefix(r.URL.Path, "/social/"); ok {75+ if strings.Contains(rest, ":") {76+ params["address"] = unescapeSegment(rest)77+ } else {78+ params["id"] = rest79+ }7880 method, tab = "browsesocialthread", "social-thread"7981 }8082 raw, err := json.Marshal(params)@@ -139,7 +141,7 @@ func socialContext(value any, data PageData) map[string]any {
139141 140142 func socialReferenceURL(row map[string]any, prefix string) string {141143 if address := plainString(row[prefix+"address"]); strings.HasPrefix(address, "30023:") {142- return "/social?address=" + url.QueryEscape(address)144+ return "/social/" + url.PathEscape(address)143145 }144146 if id := plainString(row[prefix+"id"]); hexID.MatchString(id) {145147 return "/social/" + id@@ -148,11 +150,7 @@ func socialReferenceURL(row map[string]any, prefix string) string {
148150 }149151 150152 func socialThreadPage(path string, query url.Values, cursor string) string {151- next := url.Values{}152- if address := query.Get("address"); address != "" {153- next.Set("address", address)154- }155- next.Set("cursor", cursor)153+ next := url.Values{"cursor": {cursor}}156154 return path + "?" + next.Encode()157155 }158156 @@ -195,7 +193,7 @@ func socialQuery(query url.Values, key, value string) string {
195193 func socialURL(value any) string {196194 row := valueMap(value)197195 if address := plainString(row["address"]); address != "" {198- return "/social?address=" + url.QueryEscape(address)196+ return "/social/" + url.PathEscape(address)199197 }200198 return "/social/" + url.PathEscape(plainString(row["id"]))201199 }@@ -132,7 +132,7 @@ func TestSocialContextLinksToParentAndRoot(t *testing.T) {
132132 }133133 address := "30023:" + root + ":essay:part-one"134134 row = socialContext(map[string]any{"id": parent, "parent_id": root, "parent_address": address, "root_address": address}, data)135- if row["nested"] != false || !strings.Contains(plainString(row["parent_url"]), "address=30023%3A") {135+ if row["nested"] != false || !strings.HasPrefix(plainString(row["parent_url"]), "/social/30023:") {136136 t.Fatalf("missing article parent: %#v", row)137137 }138138 }@@ -249,10 +249,10 @@ func promptPath(path string, query url.Values) string {
249249 switch {250250 case path == "":251251 return "home"252- case path == "repo" && query.Get("repo") != "":252+ case parseRepoRoute("/" + path).ok:253253 return "repos/" + query.Get("repo") + "/" + repoView(query)254- case path == "file" && (query.Get("sha") != "" || query.Get("hash") != ""):255- return "files/" + shortID(query.Get("sha")+query.Get("hash"))254+ case fileHash("/"+path) != "":255+ return "files/" + shortID(fileHash("/"+path))256256 }257257 if room := roomRoute("/" + path); room.tab == "thread" {258258 return "rooms/" + room.id + "/thread/" + shortID(room.event)@@ -266,7 +266,7 @@ func promptPath(path string, query url.Values) string {
266266 // pageAddress is the address the crumb copies: the relay's public URL plus267267 // the page's canonical path, so a link copied on a tailnet or LAN address268268 // still opens for anyone. Wiki names are normalized the way the relay269-// resolves them; repositories and files keep the query that names them.269+// resolves them; the query keeps only what still narrows the page.270270 func pageAddress(base, path string, query url.Values) string {271271 path = "/" + strings.Trim(path, "/")272272 if path == "/" {@@ -274,9 +274,16 @@ func pageAddress(base, path string, query url.Values) string {
274274 }275275 if strings.HasPrefix(path, "/wiki/") {276276 path = "/wiki/" + url.PathEscape(wikiPageName(path))277+ if id := wikiMergeID(path); id != "" {278+ path += "/proposals/" + id279+ }280+ }281+ keys := []string{"path", "ref", "q", "author", "version", "kind", "status", "label"}282+ if parseRepoRoute(path).ok {283+ keys = []string{"ref", "q", "status", "label"}277284 }278285 keep := url.Values{}279- for _, key := range []string{"owner", "repo", "view", "path", "ref", "sha", "hash", "q", "id", "author", "version", "merge", "address", "kind"} {286+ for _, key := range keys {280287 if value := query.Get(key); value != "" {281288 keep.Set(key, value)282289 }@@ -432,6 +439,9 @@ func parseTemplateBase() (*template.Template, error) {
432439 "repoCommitURL": repoCommitURL,433440 "hasNextOffset": hasNextOffset,434441 "repoURL": repoURL,442+ "repoPagePath": repoPagePath,443+ "repoPageURL": repoPageURL,444+ "repoHref": repoHref,435445 "repoBreadcrumbs": repoBreadcrumbs,436446 "browsePageURL": browsePageURL,437447 "shortID": shortID,@@ -470,22 +470,52 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
470470 return471471 }472472 path, method := request.URL.Path, ""473- switch path {474- case "/repos":473+ pageQuery := request.URL.Query()474+ for key := range routeKeys {475+ pageQuery.Del(key)476+ }477+ repo := parseRepoRoute(path)478+ switch {479+ case path == "/repos":475480 method = "browserepos"476- case "/repo":481+ case repo.ok:477482 method = "browserepo"478- case "/files":483+ owner := a.person(request.Context(), repo.owner)484+ if owner == "" {485+ http.NotFound(writer, request)486+ return487+ }488+ pageQuery.Set("owner", owner)489+ pageQuery.Set("handle", a.handle(request.Context(), owner))490+ pageQuery.Set("repo", repo.repo)491+ pageQuery.Set("view", repo.view)492+ if repo.path != "" {493+ pageQuery.Set("path", repo.path)494+ }495+ if repo.id != "" {496+ pageQuery.Set("id", repo.id)497+ }498+ if repo.view == "commit" {499+ pageQuery.Set("ref", repo.id)500+ }501+ case path == "/files":502+ method = "browsefiles"503+ case filesView(path) != "":479504 method = "browsefiles"480- case "/file":505+ pageQuery.Set("view", filesView(path))506+ case fileHash(path) != "":481507 method = "browsefile"482- case "/approvals":508+ pageQuery.Set("hash", fileHash(path))509+ case path == "/approvals":483510 method = "browseapprovals"484- case "/profile":511+ case approvalID(path) != "":512+ method = "browseapprovals"513+ pageQuery.Set("id", approvalID(path))514+ case path == "/profile":485515 method = "browseprofile"486- case "/wiki":516+ case path == "/wiki":487517 method = "browsewiki"488- case "/manage/health":518+ case path == "/manage/health":489519 method = "browsestatus"490520 }491521 room := roomRoute(path)@@ -501,7 +531,6 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
501531 method = "browsewikipage"502532 }503533 params := []json.RawMessage{}504- pageQuery := request.URL.Query()505534 query := map[string]any{"cursor": request.URL.Query().Get("cursor"), "limit": 50, "q": request.URL.Query().Get("q")}506535 if value := request.URL.Query().Get("limit"); value != "" {507536 if parsed, parseErr := strconv.Atoi(value); parseErr == nil && parsed > 0 {@@ -510,15 +539,15 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
510539 }511540 switch method {512541 case "browsefiles":513- view := request.URL.Query().Get("view")542+ view := pageQuery.Get("view")514543 if view == "" {515544 view = "library"516545 }517546 query["view"] = view518547 query["path"] = request.URL.Query().Get("path")519548 case "browserepo":520- view := request.URL.Query().Get("view")521- if view == "" || view == "home" {549+ view := pageQuery.Get("view")550+ if view == "home" {522551 view = "tree"523552 }524553 offset := 0@@ -542,16 +571,9 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
542571 method = "browsepull"543572 backendView = ""544573 }545- eventID := request.URL.Query().Get("id")546- if eventID == "" && (view == "issue" || view == "pr") {547- eventID = request.URL.Query().Get("path")548- }549- query = map[string]any{"owner": request.URL.Query().Get("owner"), "repo": request.URL.Query().Get("repo"), "event": eventID, "q": request.URL.Query().Get("q"), "state": request.URL.Query().Get("status"), "label": request.URL.Query().Get("label"), "cursor": request.URL.Query().Get("cursor"), "ref": request.URL.Query().Get("ref"), "path": request.URL.Query().Get("path"), "view": backendView, "offset": offset, "limit": 100}574+ query = map[string]any{"owner": pageQuery.Get("owner"), "repo": pageQuery.Get("repo"), "event": pageQuery.Get("id"), "q": request.URL.Query().Get("q"), "state": request.URL.Query().Get("status"), "label": request.URL.Query().Get("label"), "cursor": request.URL.Query().Get("cursor"), "ref": pageQuery.Get("ref"), "path": pageQuery.Get("path"), "view": backendView, "offset": offset, "limit": 100}550575 case "browsefile":551- query = map[string]any{"hash": request.URL.Query().Get("hash")}552- if query["hash"] == "" {553- query["hash"] = request.URL.Query().Get("sha")554- }576+ query = map[string]any{"hash": pageQuery.Get("hash")}555577 case "browseprofile":556578 query = map[string]any{"pubkey": actor}557579 case "browseapprovals":@@ -566,6 +588,9 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
566588 // The name travels in the query as well so the page template can567589 // offer to create a page that does not exist yet.568590 pageQuery.Set("d", wikiPageName(path))591+ if id := wikiMergeID(path); id != "" {592+ pageQuery.Set("merge", id)593+ }569594 query = map[string]any{"d": pageQuery.Get("d"), "author": request.URL.Query().Get("author"), "version": request.URL.Query().Get("version")}570595 case "browsestatus":571596 query = map[string]any{}@@ -577,8 +602,8 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
577602 raw, _ := json.Marshal(query)578603 params = append(params, raw)579604 var tree []any580- if method == "browserepo" && request.URL.Query().Get("view") == "file" {581- tree = a.siblings(request.Context(), actor, request.URL.Query())605+ if method == "browserepo" && pageQuery.Get("view") == "file" {606+ tree = a.siblings(request.Context(), actor, pageQuery)582607 }583608 result, err, typedRoom := a.typedRoomResult(request.Context(), room, pageQuery, actor)584609 if !typedRoom {@@ -592,15 +617,15 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
592617 a.render(writer, request, data)593618 return594619 }595- if method == "browserepo" && pageQuery.Get("view") == "" {596- pageQuery.Set("view", "tree")597- }598620 if method == "browseapprovals" && pageQuery.Get("id") != "" {599621 // A notification opens one request; show it even when it has left600622 // the first page.601623 result = a.includeApproval(request.Context(), actor, result, pageQuery.Get("id"))602624 }603625 data := PageData{Tab: browseTab(path), Feed: browseRows(result), Event: result, Query: pageQuery, View: room.id}626+ if method == "browserepos" {627+ a.addHandles(request.Context(), data.Feed, "owner")628+ }604629 data.Title = browseTitle(path, pageQuery, result, a.backend.Slug())605630 if id := pageQuery.Get("merge"); method == "browsewikipage" && len(id) == 64 {606631 raw, _ := json.Marshal(map[string]any{"id": id})@@ -639,7 +664,7 @@ func browseTitle(path string, query url.Values, result any, slug string) string
639664 label += " | thread"640665 }641666 }642- if path == "/repo" {667+ if parseRepoRoute(path).ok {643668 label = query.Get("repo")644669 if label == "" {645670 label = plainString(valueMap(result)["identifier"])@@ -702,19 +727,24 @@ func browseTab(path string) string {
702727 return "wiki"703728 case "/repos":704729 return "repos"705- case "/repo":706- return "repo"707730 case "/files":708731 return "files"709- case "/file":710- return "file"711732 case "/approvals":712733 return "approvals"713734 case "/profile":714735 return "profile"715- default:716- return "health"717736 }737+ switch {738+ case parseRepoRoute(path).ok:739+ return "repo"740+ case filesView(path) != "":741+ return "files"742+ case fileHash(path) != "":743+ return "file"744+ case approvalID(path) != "":745+ return "approvals"746+ }747+ return "health"718748 }719749 func browseRows(value any) []any {720750 encoded, err := json.Marshal(value)@@ -883,7 +913,15 @@ func (a *App) agentsPage(request *http.Request, actor string, data *PageData) {
883913 if callbacks, err := a.backend.Query(request.Context(), "listcallbacks", nil, actor); err == nil {884914 data.Callbacks = browseRows(callbacks)885915 }886- selected := request.URL.Query().Get("agent")916+ a.addHandles(request.Context(), data.Feed, "pubkey")917+ selected := ""918+ if rest, ok := strings.CutPrefix(request.URL.Path, "/manage/agents/"); ok {919+ selected = a.person(request.Context(), rest)920+ if selected == "" {921+ data.Error = "not found: agent " + rest922+ return923+ }924+ }887925 if selected == "" {888926 // Without a choice, show the first agent that can still publish.889927 for _, agent := range data.Feed {@@ -1258,6 +1296,18 @@ func tabForPath(path string) string {
12581296 if strings.HasPrefix(path, "wiki/") {12591297 return "wikipage"12601298 }1299+ switch {1300+ case parseRepoRoute("/" + path).ok:1301+ return "repo"1302+ case filesView("/"+path) != "":1303+ return "files"1304+ case fileHash("/"+path) != "":1305+ return "file"1306+ case approvalID("/"+path) != "":1307+ return "approvals"1308+ case strings.HasPrefix(path, "agents/"):1309+ return "agents"1310+ }12611311 switch path {12621312 case "manage":12631313 return "owner"@@ -1265,12 +1315,8 @@ func tabForPath(path string) string {
12651315 return "wiki"12661316 case "repos":12671317 return "repos"1268- case "repo":1269- return "repo"12701318 case "files":12711319 return "files"1272- case "file":1273- return "file"12741320 case "approvals":12751321 return "approvals"12761322 case "profile":@@ -143,6 +143,12 @@ func wikiURL(d string, pairs ...string) string {
143143 path := "/wiki/" + url.PathEscape(d)144144 values := url.Values{}145145 for i := 0; i+1 < len(pairs); i += 2 {146+ if pairs[i] == "merge" {147+ if pairs[i+1] != "" {148+ path += "/proposals/" + pairs[i+1]149+ }150+ continue151+ }146152 if pairs[i+1] != "" {147153 values.Set(pairs[i], pairs[i+1])148154 }@@ -159,9 +165,27 @@ func (a *App) wikiHTML(content any) template.HTML {
159165 160166 // wikiPageName is the name in a /wiki/<d> path, normalized.161167 func wikiPageName(path string) string {162- name, err := url.PathUnescape(strings.TrimPrefix(path, "/wiki/"))168+ rest := strings.TrimPrefix(path, "/wiki/")169+ if i := strings.Index(rest, "/proposals/"); i >= 0 {170+ rest = rest[:i]171+ }172+ name, err := url.PathUnescape(rest)163173 if err != nil {164- name = strings.TrimPrefix(path, "/wiki/")174+ name = rest165175 }166176 return wiki.Normalize(name)167177 }178+179+// wikiMergeID is the merge request behind /wiki/{page}/proposals/{id}, or "".180+func wikiMergeID(path string) string {181+ rest := strings.TrimPrefix(path, "/wiki/")182+ i := strings.Index(rest, "/proposals/")183+ if i < 0 {184+ return ""185+ }186+ id := rest[i+len("/proposals/"):]187+ if !eventIDPattern.MatchString(id) {188+ return ""189+ }190+ return strings.ToLower(id)191+}@@ -167,7 +167,7 @@ func TestWikiPageRendersArticleMergeBarAndPanel(t *testing.T) {
167167 }168168 wantAll(t, path, body,169169 `<title>Release notes 1.4 <b> | wiki | demo</title>`, `<h1>Release notes 1.4 <b></h1>`,170- `<merge-request><nostr-name pubkey="`+wikiOther+`"`, `proposes a new version of this page`, `href="/wiki/release-notes-1-4?merge=`+wikiMerge+`">compare</a>`,170+ `<merge-request><nostr-name pubkey="`+wikiOther+`"`, `proposes a new version of this page`, `href="/wiki/release-notes-1-4/proposals/`+wikiMerge+`">compare</a>`,171171 `<nostr-react event="`+wikiMerge+`" pubkey="`+wikiOther+`" kind="818"><button name="reaction" value="+">Accept</button><button name="reaction" value="-">Reject</button></nostr-react>`,172172 `<a href="/e/`+wikiMerge+`">Reply</a>`,173173 `<wiki-article><header>wiki | release-notes-1-4 | revision 1 by <nostr-name pubkey="`+wikiOwner+`"`, `version 1 of 2 | 1 fork</header>`,@@ -187,7 +187,7 @@ func TestWikiPageRendersArticleMergeBarAndPanel(t *testing.T) {
187187 188188 func TestWikiPageCompareEditorAndHistory(t *testing.T) {189189 b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}}190- path := "/wiki/release-notes-1-4?merge=" + wikiMerge190+ path := "/wiki/release-notes-1-4/proposals/" + wikiMerge191191 body := wikiGet(t, wikiApp(t, b, wikiOwner), path)192192 if b.params["browsewikimerge"]["id"] != wikiMerge {193193 t.Fatalf("merge lookup params=%v", b.params["browsewikimerge"])