|
| 1 | +package web |
| 2 | + |
| 3 | +import ( |
| 4 | + "net/http" |
| 5 | + "net/url" |
| 6 | + "sort" |
| 7 | + "strings" |
| 8 | + |
| 9 | + "github.com/labstack/echo/v5" |
| 10 | + "github.com/latebit-io/demarkus-library/internal/core/domain" |
| 11 | +) |
| 12 | + |
| 13 | +// The command palette (ADR 0006 §3) — the known-item "front door". SSR/htmx-hard |
| 14 | +// (ADR 0003): it is an htmx active-search surface, never JSON and never a client |
| 15 | +// state island. The overlay markup and every result row are server-rendered; |
| 16 | +// typing fires an hx-get that swaps in an HTML fragment, and the whole thing |
| 17 | +// degrades to the /search route without JS. The current trail rides in on the |
| 18 | +// htmx HX-Current-URL header, so both the recent list (empty query) and the |
| 19 | +// trail-extending row links are computed server-side through the trail codec — |
| 20 | +// the URL stays the single source of truth, with no client-side trail logic. |
| 21 | + |
| 22 | +// paletteMaxRows caps the rendered result list — a switcher shows the best |
| 23 | +// matches, not the whole catalog. |
| 24 | +const paletteMaxRows = 50 |
| 25 | + |
| 26 | +type paletteRow struct { |
| 27 | + Title string |
| 28 | + Loc string // world + path, the mono secondary line |
| 29 | + Status string |
| 30 | + URL string // a ready trail URL (rewind-on-dedup, else push) |
| 31 | +} |
| 32 | + |
| 33 | +type paletteVM struct { |
| 34 | + Query string |
| 35 | + Rows []paletteRow |
| 36 | +} |
| 37 | + |
| 38 | +// Palette renders the name-mode results fragment for the htmx active search. A |
| 39 | +// non-htmx request (no JS, or a direct hit) is redirected to /search: the |
| 40 | +// palette is a progressive enhancement, and /search is the durable, fully |
| 41 | +// server-rendered surface it degrades to. |
| 42 | +func (h *ReadingHandler) Palette(c *echo.Context) error { |
| 43 | + if c.Request().Header.Get("HX-Request") != "true" { |
| 44 | + target := "/search" |
| 45 | + if q := strings.TrimSpace(c.QueryParam("q")); q != "" { |
| 46 | + target += "?q=" + url.QueryEscape(q) |
| 47 | + } |
| 48 | + return c.Redirect(http.StatusSeeOther, target) |
| 49 | + } |
| 50 | + |
| 51 | + t := currentTrail(c) |
| 52 | + q := strings.TrimSpace(c.QueryParam("q")) |
| 53 | + |
| 54 | + var rows []paletteRow |
| 55 | + if q == "" { |
| 56 | + rows = recentRows(t) // "get back to where I was" is the common retrieval |
| 57 | + } else { |
| 58 | + world := c.QueryParam("world") |
| 59 | + if world == "" { |
| 60 | + world = paletteWorld(c, t, h.defaultWorld) |
| 61 | + } |
| 62 | + entries, err := h.reading.NameIndex(c.Request().Context(), c.QueryParam("scope"), world) |
| 63 | + if err != nil { |
| 64 | + // Surface real failures (re-login, unreachable world) instead of |
| 65 | + // rendering an outage as "no matches". |
| 66 | + return presentError(c, err, world, "/palette") |
| 67 | + } |
| 68 | + rows = matchRows(t, q, entries) |
| 69 | + } |
| 70 | + return c.Render(http.StatusOK, "palette-results", paletteVM{Query: q, Rows: rows}) |
| 71 | +} |
| 72 | + |
| 73 | +// currentTrail parses the reader's current trail from the htmx HX-Current-URL |
| 74 | +// header. Off a /t/ page (the floor, a permalink) or on any parse failure it is |
| 75 | +// the empty trail (Focus -1) — recent is then empty and a jump starts fresh. |
| 76 | +func currentTrail(c *echo.Context) trail { |
| 77 | + cur := c.Request().Header.Get("HX-Current-URL") |
| 78 | + if cur == "" { |
| 79 | + return trail{Focus: -1} |
| 80 | + } |
| 81 | + u, err := url.Parse(cur) |
| 82 | + if err != nil { |
| 83 | + return trail{Focus: -1} |
| 84 | + } |
| 85 | + rest, ok := strings.CutPrefix(u.Path, "/t/") |
| 86 | + if !ok { |
| 87 | + return trail{Focus: -1} |
| 88 | + } |
| 89 | + t, err := parseTrail(rest, u.Query().Get("focus"), u.Query().Get("reader")) |
| 90 | + if err != nil { |
| 91 | + return trail{Focus: -1} |
| 92 | + } |
| 93 | + return t |
| 94 | +} |
| 95 | + |
| 96 | +// paletteWorld is the world the search scopes to by default: the focused pane's |
| 97 | +// world on a trail, the /w/<world>/ world on a permalink page, else the default |
| 98 | +// (the floor and other world-less pages). |
| 99 | +func paletteWorld(c *echo.Context, t trail, fallback string) string { |
| 100 | + if t.Focus >= 0 && t.Focus < len(t.Panes) && t.Panes[t.Focus].World != "" { |
| 101 | + return t.Panes[t.Focus].World |
| 102 | + } |
| 103 | + if w := worldFromURL(c.Request().Header.Get("HX-Current-URL")); w != "" { |
| 104 | + return w |
| 105 | + } |
| 106 | + return fallback |
| 107 | +} |
| 108 | + |
| 109 | +// worldFromURL pulls the world out of a /w/<world>/... current-URL path, |
| 110 | +// returning "" when the path is not a /w/ permalink. |
| 111 | +func worldFromURL(cur string) string { |
| 112 | + if cur == "" { |
| 113 | + return "" |
| 114 | + } |
| 115 | + u, err := url.Parse(cur) |
| 116 | + if err != nil { |
| 117 | + return "" |
| 118 | + } |
| 119 | + rest, ok := strings.CutPrefix(u.Path, "/w/") |
| 120 | + if !ok { |
| 121 | + return "" |
| 122 | + } |
| 123 | + w, _, _ := strings.Cut(rest, "/") |
| 124 | + if dec, derr := url.PathUnescape(w); derr == nil { |
| 125 | + w = dec |
| 126 | + } |
| 127 | + return w |
| 128 | +} |
| 129 | + |
| 130 | +// recentRows is the empty-query view: the trail in reverse (most-recent first), |
| 131 | +// the active pane skipped. Each row rewinds (focuses) its pane. |
| 132 | +func recentRows(t trail) []paletteRow { |
| 133 | + var rows []paletteRow |
| 134 | + for i := len(t.Panes) - 1; i >= 0; i-- { |
| 135 | + if i == t.Focus { |
| 136 | + continue |
| 137 | + } |
| 138 | + title, loc := paneLabel(t.Panes[i]) |
| 139 | + rows = append(rows, paletteRow{Title: title, Loc: loc, URL: trailURL(trailFocused(t, i))}) |
| 140 | + } |
| 141 | + return rows |
| 142 | +} |
| 143 | + |
| 144 | +// matchRows fuzzy-filters the catalog and builds a trail-extending link per hit. |
| 145 | +// A jump pushes onto the end of the trail (trailAfterClick rewinds instead if |
| 146 | +// the doc is already on it). |
| 147 | +func matchRows(t trail, q string, entries []domain.IndexEntry) []paletteRow { |
| 148 | + idx := len(t.Panes) - 1 // -1 on an empty trail ⇒ a jump starts a fresh one |
| 149 | + ql := strings.ToLower(q) |
| 150 | + type hit struct { |
| 151 | + e domain.IndexEntry |
| 152 | + rank int |
| 153 | + } |
| 154 | + var hits []hit |
| 155 | + for _, e := range entries { |
| 156 | + if r, ok := matchRank(ql, strings.ToLower(e.Title+" "+e.World+e.Path)); ok { |
| 157 | + hits = append(hits, hit{e, r}) |
| 158 | + } |
| 159 | + } |
| 160 | + sort.SliceStable(hits, func(i, j int) bool { return hits[i].rank < hits[j].rank }) |
| 161 | + if len(hits) > paletteMaxRows { |
| 162 | + hits = hits[:paletteMaxRows] |
| 163 | + } |
| 164 | + rows := make([]paletteRow, 0, len(hits)) |
| 165 | + for _, h := range hits { |
| 166 | + target := paneAddr{Kind: paneDoc, World: h.e.World, Value: h.e.Path} |
| 167 | + rows = append(rows, paletteRow{ |
| 168 | + Title: h.e.Title, |
| 169 | + Loc: h.e.World + h.e.Path, |
| 170 | + Status: h.e.Status, |
| 171 | + URL: trailURL(trailAfterClick(t, idx, target)), |
| 172 | + }) |
| 173 | + } |
| 174 | + return rows |
| 175 | +} |
| 176 | + |
| 177 | +// matchRank scores a match: a substring hit ranks by its position (earlier is |
| 178 | +// better); a looser subsequence hit ranks behind every substring hit. Not a |
| 179 | +// match ⇒ ok=false. |
| 180 | +func matchRank(q, hay string) (int, bool) { |
| 181 | + if i := strings.Index(hay, q); i >= 0 { |
| 182 | + return i, true |
| 183 | + } |
| 184 | + j := 0 |
| 185 | + for i := 0; i < len(hay) && j < len(q); i++ { |
| 186 | + if hay[i] == q[j] { |
| 187 | + j++ |
| 188 | + } |
| 189 | + } |
| 190 | + if j == len(q) { |
| 191 | + return 1000, true |
| 192 | + } |
| 193 | + return 0, false |
| 194 | +} |
| 195 | + |
| 196 | +// paneLabel gives a recent row its title + location from a pane address. |
| 197 | +func paneLabel(p paneAddr) (title, loc string) { |
| 198 | + switch p.Kind { |
| 199 | + case paneFloor: |
| 200 | + if p.World == "" { |
| 201 | + return "universe", "" |
| 202 | + } |
| 203 | + return p.World + " — map", p.World |
| 204 | + case paneTag: |
| 205 | + return "#" + p.Value, p.World |
| 206 | + default: // paneDoc, paneGraph |
| 207 | + name := strings.TrimSuffix(p.Value[strings.LastIndex(p.Value, "/")+1:], ".md") |
| 208 | + if name == "" { |
| 209 | + name = p.Value |
| 210 | + } |
| 211 | + if p.Kind == paneGraph { |
| 212 | + name = "graph: " + name |
| 213 | + } |
| 214 | + return name, p.World + p.Value |
| 215 | + } |
| 216 | +} |
0 commit comments