", Label: "Open"},
+ {Key: "", Label: "Pin"},
+ {Key: ">", Label: "Help"},
+ {Key: "", Label: "Quit"},
+ }
+}
+
+func (v *PickerView) Render(width, height int, _ *ui.AppCtx) string {
+ p := ui.Active
+
+ // Card width — clamp to mock's 460px ≈ 60 cells with a bit of slack.
+ cardW := 64
+ if width < 70 {
+ cardW = width - 4
+ }
+
+ // ── Search bar / title ──
+ cursor := lipgloss.NewStyle().Background(p.Cursor).Render(" ")
+ searchVal := lipgloss.NewStyle().Foreground(p.Body).Render(v.query)
+ prompt := lipgloss.NewStyle().Foreground(p.Accent).Bold(true).Render("›")
+ count := lipgloss.NewStyle().Foreground(p.Faint).Render(
+ fmt.Sprintf("%d/%d", len(v.filtered), len(v.items)),
+ )
+ searchRow := lipgloss.JoinHorizontal(lipgloss.Top,
+ " ", prompt, " ", searchVal, cursor,
+ )
+ gap := cardW - lipgloss.Width(searchRow) - lipgloss.Width(count) - 2
+ if gap < 1 {
+ gap = 1
+ }
+ titleBar := lipgloss.NewStyle().
+ Background(p.Panel).
+ BorderStyle(lipgloss.NormalBorder()).
+ BorderBottom(true).
+ BorderForeground(p.Border).
+ Width(cardW).
+ Render(searchRow + strings.Repeat(" ", gap) + count + " ")
+
+ // ── List body ──
+ maxRows := height - 8 // chrome + footer
+ if maxRows < 4 {
+ maxRows = 4
+ }
+ var rows []string
+ switch {
+ case v.loading:
+ rows = []string{lipgloss.NewStyle().Foreground(p.Faint).Padding(1, 2).Render("loading datasets…")}
+ case v.err != "":
+ rows = []string{lipgloss.NewStyle().Foreground(p.Err).Padding(1, 2).Render(v.err)}
+ case len(v.filtered) == 0:
+ rows = []string{lipgloss.NewStyle().Foreground(p.Faint).Padding(1, 2).Render("(no matches)")}
+ default:
+ start := 0
+ if v.cursor >= maxRows {
+ start = v.cursor - maxRows + 1
+ }
+ end := start + maxRows
+ if end > len(v.filtered) {
+ end = len(v.filtered)
+ }
+ for i := start; i < end; i++ {
+ rows = append(rows, renderPickerRow(v.filtered[i], i == v.cursor, v.pinned[v.filtered[i].Name], v.query, cardW))
+ }
+ }
+ listBody := strings.Join(rows, "\n")
+
+ // ── Footer hint row ──
+ hint := func(k, lbl string) string {
+ return lipgloss.NewStyle().Foreground(p.Accent).Render(k) +
+ " " + lipgloss.NewStyle().Foreground(p.Dim).Render(lbl)
+ }
+ footerLeft := lipgloss.JoinHorizontal(lipgloss.Top,
+ hint("↑↓", "nav"), " ",
+ hint("↵", "open"), " ",
+ hint("p", "pin"),
+ )
+ footerRight := hint("esc", "")
+ fgap := cardW - lipgloss.Width(footerLeft) - lipgloss.Width(footerRight) - 4
+ if fgap < 1 {
+ fgap = 1
+ }
+ footer := lipgloss.NewStyle().
+ Background(p.PanelAlt).
+ BorderStyle(lipgloss.NormalBorder()).
+ BorderTop(true).
+ BorderForeground(p.Border).
+ Padding(0, 2).
+ Width(cardW).
+ Render(footerLeft + strings.Repeat(" ", fgap) + footerRight)
+
+ card := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(p.BorderHi).
+ Background(p.Panel).
+ Render(lipgloss.JoinVertical(lipgloss.Left, titleBar, listBody, footer))
+
+ // Center the card inside body.
+ return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, card,
+ lipgloss.WithWhitespaceChars(" "),
+ lipgloss.WithWhitespaceForeground(p.Bg),
+ )
+}
+
+// renderPickerRow draws one dataset row in the picker:
+//
+// [rail] [cursor] name (match highlighted) [kind] [Nf]
+func renderPickerRow(d dsItem, selected, pinned bool, query string, width int) string {
+ p := ui.Active
+
+ rail := " "
+ if selected {
+ rail = lipgloss.NewStyle().Background(p.Accent).Render(" ")
+ }
+ bg := p.Bg
+ if selected {
+ bg = p.SelRow
+ }
+
+ cursorGlyph := "·"
+ cursorFg := p.Body
+ switch {
+ case selected:
+ cursorGlyph = "▸"
+ cursorFg = p.Accent
+ case pinned:
+ cursorGlyph = "★"
+ cursorFg = p.Accent2
+ }
+
+ // Match highlight: lowercase substring → wrap span in accent color.
+ name := d.Name
+ nameFg := p.Body
+ if selected {
+ nameFg = p.Text
+ }
+ var nameRendered string
+ if query != "" {
+ idx := strings.Index(strings.ToLower(name), strings.ToLower(query))
+ if idx >= 0 {
+ nameRendered = lipgloss.NewStyle().Foreground(nameFg).Background(bg).Render(name[:idx]) +
+ lipgloss.NewStyle().Foreground(p.Accent).Background(bg).Bold(true).Render(name[idx:idx+len(query)]) +
+ lipgloss.NewStyle().Foreground(nameFg).Background(bg).Render(name[idx+len(query):])
+ } else {
+ nameRendered = lipgloss.NewStyle().Foreground(nameFg).Background(bg).Render(name)
+ }
+ } else {
+ nameRendered = lipgloss.NewStyle().Foreground(nameFg).Background(bg).Render(name)
+ }
+
+ kindFg := kindColor(d.Kind)
+ kindCol := lipgloss.NewStyle().
+ Foreground(kindFg).Background(bg).
+ Width(10).
+ Align(lipgloss.Right).
+ Render(d.Kind)
+
+ fieldCol := lipgloss.NewStyle().
+ Foreground(p.Faint).Background(bg).
+ Width(5).
+ Align(lipgloss.Right).
+ Render(fmt.Sprintf("%df", d.Fields))
+
+ // Cursor column (3 cells) + flex name + kind + fields = total width
+ cursorCell := lipgloss.NewStyle().
+ Foreground(cursorFg).Background(bg).
+ Width(3).
+ Align(lipgloss.Center).
+ Render(cursorGlyph)
+
+ nameW := width - 1 - 3 - 10 - 5 - 2 // rail + cursor + kind + field + slack
+ if nameW < 6 {
+ nameW = 6
+ }
+ nameCell := lipgloss.NewStyle().
+ Background(bg).
+ Width(nameW).
+ Render(nameRendered)
+
+ return rail + cursorCell + nameCell + kindCol + fieldCol
+}
+
+func kindColor(kind string) lipgloss.Color {
+ p := ui.Active
+ switch kind {
+ case "metrics":
+ return p.OkSoft
+ case "events":
+ return p.Warn
+ case "traces":
+ return p.String
+ }
+ return p.Accent // logs (default)
+}
+
+// fetchDatasets calls /api/v1/logstream and infers kind from name.
+// (Mirrors fetchMetricDatasets in pkg/model/promql.go but kept local so
+// the greenfield UI has no legacy dependency.)
+func fetchDatasets(profile config.Profile) tea.Cmd {
+ return func() tea.Msg {
+ client := &http.Client{Timeout: 15 * time.Second}
+ req, err := http.NewRequest("GET", strings.TrimRight(profile.URL, "/")+"/api/v1/logstream", nil)
+ if err != nil {
+ return datasetListMsg{err: err.Error()}
+ }
+ if profile.Token != "" {
+ req.Header.Set("Authorization", "Bearer "+profile.Token)
+ } else {
+ req.SetBasicAuth(profile.Username, profile.Password)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return datasetListMsg{err: err.Error()}
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode >= 400 {
+ return datasetListMsg{err: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, truncate(string(body), 200))}
+ }
+ var raw []struct {
+ Name string `json:"name"`
+ }
+ if err := json.Unmarshal(body, &raw); err != nil {
+ return datasetListMsg{err: err.Error()}
+ }
+ out := make([]dsItem, 0, len(raw))
+ for _, r := range raw {
+ out = append(out, dsItem{
+ Name: r.Name,
+ Kind: inferKind(r.Name),
+ Fields: 0, // populated lazily on selection
+ })
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
+ return datasetListMsg{items: out}
+ }
+}
+
+func inferKind(name string) string {
+ n := strings.ToLower(name)
+ switch {
+ case strings.Contains(n, "metric"):
+ return "metrics"
+ case strings.Contains(n, "trace"):
+ return "traces"
+ case strings.Contains(n, "event"):
+ return "events"
+ default:
+ return "logs"
+ }
+}
+
+func truncate(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return s[:n] + "…"
+}
+
+func max0(n int) int {
+ if n < 0 {
+ return 0
+ }
+ return n
+}
diff --git a/pkg/ui/views/views.go b/pkg/ui/views/views.go
new file mode 100644
index 0000000..3c33dd9
--- /dev/null
+++ b/pkg/ui/views/views.go
@@ -0,0 +1,56 @@
+// Copyright (c) 2024 Parseable, Inc
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Package views holds the per-screen render + Update logic. Each file
+// implements one ViewID. App in pkg/ui wires them.
+package views
+
+import (
+ "pb/pkg/ui"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+// Placeholder renders the "not built yet" body for views that have not
+// been implemented during the greenfield migration. It accepts the
+// active view label and a one-line hint.
+//
+// Once each view (results / metrics / time / saved / help) gets its
+// real implementation, this helper is deleted.
+type Placeholder struct {
+ Title string
+ Hint string
+}
+
+func (Placeholder) Init() (cmd interface{ Run() }) { return nil }
+
+func renderEmptyBody(width, height int, title, hint string) string {
+ p := ui.Active
+ t := lipgloss.NewStyle().
+ Foreground(p.Faint).
+ Bold(true).
+ Render(title)
+ h := lipgloss.NewStyle().
+ Foreground(p.Ghost).
+ MarginTop(1).
+ Render(hint)
+ body := lipgloss.JoinVertical(lipgloss.Center, t, h)
+ return lipgloss.NewStyle().
+ Width(width).
+ Height(height).
+ Background(p.Bg).
+ Align(lipgloss.Center, lipgloss.Center).
+ Render(body)
+}