Files
go-nc-exapp/navigation.go
T
konradandCursor 7a915aee7b Add an optional App navigation shell for ExApp pages.
ExApps that mount it share one Files-style tree; an ExApp that never mounts it keeps its own page.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 19:31:36 +02:00

212 lines
6.1 KiB
Go

package gonexapp
import (
"html/template"
"net/http"
)
// shellCSP lets the ExApp iframe render this document. AppAPI blanks a frame
// whose response omits frame-ancestors.
const shellCSP = "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:"
// Item is one App navigation entry. The ExApp chooses the ids and labels.
// Children may nest. The shell does not interpret the ids.
type Item struct {
ID string
Label string
Children []Item
}
// AppNavigation is the Files-style shell an ExApp mounts.
// Items, Page, and DefaultID are required. Header and MissingMessage are
// optional. SelectKey defaults to "item". Every other query parameter,
// including the Visit folder, is copied onto the corrected address.
// An ExApp that never calls Handler keeps its own page.
type AppNavigation struct {
Items func(*http.Request) []Item
Page func(*http.Request, string) (string, bool)
Header func(*http.Request) string
DefaultID string
MissingMessage string
SelectKey string
// Title is the document title. Empty means "App navigation".
Title string
}
// Handler serves the shell. The selected item is the SelectKey query parameter.
// A missing item renders DefaultID, includes MissingMessage, and publishes the
// corrected query on data-address so the page can replace the address.
// Every parent starts expanded. Folding is client state for this document only.
func (n AppNavigation) Handler() http.Handler {
return http.HandlerFunc(n.serve)
}
func (n AppNavigation) selectKey() string {
if n.SelectKey != "" {
return n.SelectKey
}
return "item"
}
func (n AppNavigation) serve(w http.ResponseWriter, r *http.Request) {
var items []Item
if n.Items != nil {
items = n.Items(r)
}
asked := r.URL.Query().Get(n.selectKey())
selected := n.DefaultID
missing := false
if asked != "" {
if _, ok := findItem(items, asked); ok {
selected = asked
} else {
missing = true
}
}
page := ""
if n.Page != nil {
body, ok := n.Page(r, selected)
if !ok && selected != n.DefaultID {
missing = true
selected = n.DefaultID
body, ok = n.Page(r, selected)
}
if ok {
page = body
}
}
header := ""
if n.Header != nil {
header = n.Header(r)
}
msg := ""
if missing {
msg = n.MissingMessage
}
title := n.Title
if title == "" {
title = "App navigation"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Security-Policy", shellCSP)
w.WriteHeader(http.StatusOK)
// Status is already committed; a template error cannot change the response.
_ = shellTmpl.Execute(w, shellView{
Selected: selected,
Address: n.address(r, selected),
Header: template.HTML(header),
Items: viewItems(r, items, selected, n.address),
Page: template.HTML(page),
Missing: msg,
Title: title,
})
}
func (n AppNavigation) address(r *http.Request, id string) string {
q := r.URL.Query()
q.Set(n.selectKey(), id)
return "?" + q.Encode()
}
func findItem(items []Item, id string) (Item, bool) {
for _, it := range items {
if it.ID == id {
return it, true
}
if child, ok := findItem(it.Children, id); ok {
return child, true
}
}
return Item{}, false
}
type shellView struct {
Selected string
Address string
Header template.HTML
Items []itemView
Page template.HTML
Missing string
Title string
}
type itemView struct {
ID string
Label string
Href string
Current bool
HasChildren bool
Children []itemView
}
func viewItems(r *http.Request, items []Item, selected string, href func(*http.Request, string) string) []itemView {
out := make([]itemView, 0, len(items))
for _, it := range items {
children := viewItems(r, it.Children, selected, href)
out = append(out, itemView{
ID: it.ID,
Label: it.Label,
Href: href(r, it.ID),
Current: it.ID == selected,
HasChildren: len(children) > 0,
Children: children,
})
}
return out
}
var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{.Title}}</title>
<style>
body { margin: 0; font: 16px/1.4 sans-serif; }
#app-nav { display: flex; flex-direction: column; min-height: 100vh; }
#nav-header { padding: 0.75rem 1rem; border-bottom: 1px solid #ccc; }
#app-nav-body { display: flex; flex: 1; }
nav { width: 16rem; flex: none; border-right: 1px solid #ccc; padding: 0.5rem 0; }
main { flex: 1; padding: 1rem; }
nav ul { list-style: none; margin: 0; padding-left: 0.75rem; }
nav li { margin: 0.15rem 0; }
nav a[aria-current="page"] { font-weight: 700; }
li[data-expanded="false"] > ul { display: none; }
button.fold { border: 0; background: transparent; cursor: pointer; }
#nav-missing { margin: 0 0 1rem; }
</style>
</head>
<body data-selected="{{.Selected}}" data-address="{{.Address}}">
<div id="app-nav">
<div id="nav-header">{{.Header}}</div>
<div id="app-nav-body">
<nav aria-label="App navigation">
{{template "items" .Items}}
</nav>
<main>
{{if .Missing}}<p id="nav-missing">{{.Missing}}</p>{{end}}
{{.Page}}
</main>
</div>
</div>
<script>
(function () {
var addr = document.body.getAttribute("data-address");
if (addr && addr !== location.search) {
history.replaceState(null, "", addr);
}
document.querySelectorAll("button.fold").forEach(function (btn) {
btn.addEventListener("click", function () {
var li = btn.closest("li");
var open = li.getAttribute("data-expanded") === "true";
li.setAttribute("data-expanded", open ? "false" : "true");
btn.setAttribute("aria-expanded", open ? "false" : "true");
});
});
})();
</script>
</body>
</html>
{{define "items"}}<ul>{{range .}}<li data-id="{{.ID}}"{{if .HasChildren}} data-expanded="true"{{end}}>{{if .HasChildren}}<button type="button" class="fold" aria-expanded="true" aria-label="Fold {{.Label}}"></button>{{end}}<a href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{.Label}}</a>{{if .HasChildren}}{{template "items" .Children}}{{end}}</li>{{end}}</ul>{{end}}
`))