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>
212 lines
6.1 KiB
Go
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}}
|
|
`))
|