v0.1.0 initial commit

This commit is contained in:
2026-08-06 10:18:22 -04:00
commit 28e42523e1
31 changed files with 5055 additions and 0 deletions
+550
View File
@@ -0,0 +1,550 @@
package app
import (
"cmp"
"fmt"
"path/filepath"
"slices"
"strings"
"time"
"github.com/lixenwraith/terminal/tui"
"github.com/lixenwraith/vif-log/internal/export"
"github.com/lixenwraith/vif-log/internal/filter"
"github.com/lixenwraith/vif-log/internal/logfile"
)
// sortDir is the display order of the sort column.
type sortDir uint8
const (
sortNone sortDir = iota
sortAsc
sortDesc
)
func (d sortDir) arrow() rune { return [...]rune{' ', '↑', '↓'}[d] }
func (d sortDir) String() string {
return [...]string{"off", "asc", "desc"}[d]
}
// sortable reports whether a column's key lives in the index row. Sorting on
// fields would parse the whole view on every keystroke.
func sortable(c logfile.Column) bool {
return c == logfile.ColTime || c == logfile.ColTick ||
c == logfile.ColSub || c == logfile.ColMsg
}
// --- view and display order ------------------------------------------------
// sorted reports whether a current sorted order exists.
func (a *App) sorted() bool {
return a.sortDir != sortNone && len(a.order) == len(a.view) && len(a.view) > 0
}
// rows returns the display order: the index-ordered view unless a sort is
// active and its result is current.
func (a *App) rows() []int32 {
if a.sorted() {
return a.order
}
return a.view
}
// rebuild restarts the filter pass, keeping the focused record on the same
// screen row so a filter change never scrolls the list.
func (a *App) rebuild() {
anchor := a.cursorRec()
row := min(max(a.cursor-a.scroll, 0), max(a.listH-1, 0))
a.stack.Compile()
a.view = a.view[:0]
a.build = viewBuilder{busy: true}
a.filterStep(firstPassBudget)
if !a.build.busy {
a.applySort() // the order is only meaningful over a complete pass
}
a.seek(anchor)
a.scroll = a.cursor - row
a.clamp()
}
// filterStep tests records until the deadline, appending survivors to the view.
func (a *App) filterStep(budget time.Duration) {
metas := a.idx.Metas()
n := len(metas)
if a.build.next >= n {
a.build.busy = false
return
}
a.fctx.Bind(a.idx, a.frd)
deadline := time.Now().Add(budget)
i := a.build.next
for i < n {
end := min(i+budgetCheck, n)
for ; i < end; i++ {
a.fctx.Reset(i, metas[i])
if a.stack.Match(&a.fctx) {
a.view = append(a.view, int32(i))
}
}
if time.Now().After(deadline) {
break
}
}
a.build.next = i
a.build.busy = i < n
}
// applySort rebuilds the display order from index-resident keys.
func (a *App) applySort() {
if a.sortDir == sortNone || !sortable(a.sortCol) {
a.order = a.order[:0]
return
}
a.order = append(a.order[:0], a.view...)
metas := a.idx.Metas()
key := a.sortKey()
desc := a.sortDir == sortDesc
slices.SortStableFunc(a.order, func(x, y int32) int {
c := cmp.Compare(key(metas[x]), key(metas[y]))
if desc {
c = -c
}
if c != 0 {
return c
}
return cmp.Compare(x, y) // ties keep chronological order
})
}
func (a *App) sortKey() func(logfile.Meta) int64 {
switch a.sortCol {
case logfile.ColTick:
return func(m logfile.Meta) int64 { return int64(m.Tick) }
case logfile.ColSub:
r := ranks(a.idx.Subs())
return func(m logfile.Meta) int64 { return rankOf(r, int(m.Sub)) }
case logfile.ColMsg:
r := ranks(a.idx.Msgs())
return func(m logfile.Meta) int64 { return rankOf(r, int(m.Msg)) }
default:
return func(m logfile.Meta) int64 { return m.TS }
}
}
// ranks maps interned ids to alphabetical position so the sort compares ints.
func ranks(names []string) []int32 {
ord := make([]int32, len(names))
for i := range ord {
ord[i] = int32(i)
}
slices.SortFunc(ord, func(x, y int32) int { return strings.Compare(names[x], names[y]) })
out := make([]int32, len(names))
for r, id := range ord {
out[id] = int32(r)
}
return out
}
func rankOf(r []int32, id int) int64 {
if id < 0 || id >= len(r) {
return -1
}
return int64(r[id])
}
// cycleSort advances the sort on the focused column.
func (a *App) cycleSort() {
if !sortable(a.col) {
a.say(tui.ToastWarning, "sort: "+a.col.String()+" has no index key")
return
}
if a.sortCol != a.col {
a.sortCol, a.sortDir = a.col, sortNone
}
switch a.sortDir {
case sortNone:
a.sortDir = sortAsc
case sortAsc:
a.sortDir = sortDesc
default:
a.sortDir = sortNone
}
anchor := a.cursorRec()
a.applySort()
a.seek(anchor)
a.clamp()
}
// --- cursor ----------------------------------------------------------------
func (a *App) cursorRec() int32 {
rows := a.rows()
if a.cursor < 0 || a.cursor >= len(rows) {
return -1
}
return rows[a.cursor]
}
func (a *App) meta(rec int32) (logfile.Meta, bool) {
metas := a.idx.Metas()
if rec < 0 || int(rec) >= len(metas) {
return logfile.Meta{}, false
}
return metas[rec], true
}
// indexOf locates rec in the display order. Unsorted, a miss yields the
// insertion point — the nearest following record; sorted, it yields -1.
func (a *App) indexOf(rec int32) (int, bool) {
if a.sorted() {
i := slices.Index(a.order, rec)
return i, i >= 0
}
return slices.BinarySearch(a.view, rec)
}
// seek places the cursor on rec, falling back to its snapshot head when rec
// was filtered out — the head survives collapse.
func (a *App) seek(rec int32) {
rows := a.rows()
if rec < 0 || len(rows) == 0 {
a.cursor = 0
return
}
i, found := a.indexOf(rec)
if !found {
if m, ok := a.meta(rec); ok {
if s, ok := a.idx.SnapshotOf(m); ok {
if j, ok := a.indexOf(int32(s.Head)); ok {
i, found = j, true
}
}
}
}
if !found && i < 0 {
i = a.cursor
}
a.cursor = min(max(i, 0), len(rows)-1)
}
func (a *App) clamp() {
n := len(a.rows())
if n == 0 {
a.cursor, a.scroll = 0, 0
return
}
a.cursor = tui.ClampCursor(a.cursor, n)
a.scroll = tui.ClampScroll(a.scroll, a.listH, n)
a.scroll = tui.AdjustScroll(a.cursor, a.scroll, a.listH, n)
}
func (a *App) move(d int) {
a.cursor += d
a.dscroll = 0
a.clamp()
}
// --- follow ----------------------------------------------------------------
// followKey identifies records that look the same as the focused one: the
// interned (sub, msg) pair plus the first string field, which is what varies
// within a pair — ev for event dispatch, service for service records.
type followKey struct {
sub uint16
msg uint32
val string
}
func (a *App) followKeyOf(rec int32) (followKey, bool) {
m, ok := a.meta(rec)
if !ok {
return followKey{}, false
}
k := followKey{sub: m.Sub, msg: m.Msg}
if line, err := a.rd.Line(m); err == nil {
a.rec.Parse(m, line)
k.val = a.rec.FollowValue()
}
return k, true
}
// followJump moves to the next record sharing the focused record's key. The
// index pre-check means only candidate lines are read.
func (a *App) followJump(dir int) {
rows := a.rows()
cur := a.cursorRec()
if cur < 0 {
return
}
k, ok := a.followKeyOf(cur)
if !ok {
return
}
metas := a.idx.Metas()
for i, n := a.cursor+dir, 0; i >= 0 && i < len(rows) && n < followScanCap; i, n = i+dir, n+1 {
m := metas[rows[i]]
if m.Sub != k.sub || m.Msg != k.msg {
continue
}
if k.val != "" {
line, err := a.rd.Line(m)
if err != nil {
continue
}
a.rec.Parse(m, line)
if a.rec.FollowValue() != k.val {
continue
}
}
a.cursor = i
a.dscroll = 0
a.clamp()
return
}
a.say(tui.ToastWarning, "no more "+a.followLabel(k))
}
func (a *App) followLabel(k followKey) string {
s := logfile.Dash(a.idx.SubName(k.sub)) + "/" + logfile.Dash(a.idx.MsgName(k.msg))
if k.val != "" {
s += " " + k.val
}
return s
}
// --- snapshot, pins, column ------------------------------------------------
// toggleSnapshot expands or collapses the group under the cursor, anchoring on
// the head so the surrounding rows stay put.
func (a *App) toggleSnapshot() {
m, ok := a.meta(a.cursorRec())
if !ok || m.Snap == 0 {
a.say(tui.ToastInfo, "not a stat snapshot")
return
}
if s, ok := a.idx.SnapshotOf(m); ok {
if i, found := a.indexOf(int32(s.Head)); found {
a.cursor = i
}
}
a.snap.ToggleGroup(m.Snap)
a.rebuild()
}
func (a *App) togglePin() {
rec := a.cursorRec()
if rec < 0 {
return
}
a.pins.Toggle(rec)
if a.pinOnly.On {
a.rebuild()
return
}
a.move(1) // pinning a run should not need two keys per record
}
func (a *App) togglePinOnly() {
if !a.pinOnly.On && a.pins.Len() == 0 {
a.say(tui.ToastWarning, "no pinned records")
return
}
a.pinOnly.On = !a.pinOnly.On
a.rebuild()
}
func (a *App) clearPins() {
n := a.pins.Len()
a.pins.Clear()
a.pinOnly.On = false
a.rebuild()
a.say(tui.ToastInfo, fmt.Sprintf("cleared %d pin(s)", n))
}
// cycleColumn moves the focus, re-running an active search in the new scope.
func (a *App) cycleColumn(d int) {
a.col = a.col.Next(d)
if a.find.Active() {
_ = a.find.Set(a.find.Query, a.col)
a.rebuild()
}
}
// nextSnapshot jumps the cursor to the nearest downward snapshot head.
func (a *App) nextSnapshot() {
rows := a.rows()
if len(rows) == 0 {
return
}
start := a.cursor + 1
metas := a.idx.Metas()
for i := start; i < len(rows); i++ {
rec := rows[i]
if int(rec) < len(metas) && metas[rec].Flags&logfile.FlagSnapHead != 0 {
a.cursor = i
a.dscroll = 0
a.clamp()
return
}
}
a.say(tui.ToastWarning, "no more snapshots below")
}
// prevSnapshot jumps the cursor to the nearest upward snapshot head.
func (a *App) prevSnapshot() {
rows := a.rows()
if len(rows) == 0 {
return
}
start := a.cursor - 1
metas := a.idx.Metas()
for i := start; i >= 0; i-- {
rec := rows[i]
if int(rec) < len(metas) && metas[rec].Flags&logfile.FlagSnapHead != 0 {
a.cursor = i
a.dscroll = 0
a.clamp()
return
}
}
a.say(tui.ToastWarning, "no more snapshots above")
}
// --- prompt: search and export ---------------------------------------------
type promptKind uint8
const (
prFind promptKind = iota
prFilter
prExport
)
// prefix labels the prompt line and identifies the pending action.
func (k promptKind) prefix(col logfile.Column) string {
switch k {
case prExport:
return "export to: "
case prFilter:
return "filter: "
}
return "/" + col.String() + " "
}
func (a *App) openPrompt(k promptKind, initial string) {
a.promptKind = k
a.prompt = tui.NewTextFieldState(initial)
a.overlay = ovPrompt
}
func (a *App) openSearch() { a.openPrompt(prFind, a.find.Query) }
func (a *App) openExport() {
if a.idx == nil {
return
}
a.openPrompt(prExport, defaultExportName(a.title))
}
func (a *App) openFilter() { a.openPrompt(prFilter, "") }
func (a *App) commitPrompt() {
a.overlay = ovNone
switch a.promptKind {
case prFind:
if err := a.find.Set(a.prompt.Value(), a.col); err != nil {
a.say(tui.ToastError, err.Error())
return
}
a.rebuild()
case prFilter:
if err := a.applyFilterSpec(strings.TrimSpace(a.prompt.Value())); err != nil {
a.say(tui.ToastError, err.Error())
return
}
a.rebuild()
case prExport:
a.runExport(strings.TrimSpace(a.prompt.Value()))
}
}
// clearState drops the active search and any dynamically added stack filters,
// leaving core persistent filters (level, snap, pin) intact.
func (a *App) clearState() {
changed := false
if a.find.Active() {
_ = a.find.Set("", a.col)
changed = true
}
var keep []filter.Entry
for _, e := range a.stack.Entries {
switch e.F.Kind() {
case "level", "snap", "pin", "find":
keep = append(keep, e)
default:
changed = true
}
}
if changed {
a.stack.Entries = keep
a.rebuild()
}
}
// exportSet returns the records to export: the pin buffer when it holds
// anything, otherwise the current result.
func (a *App) exportSet() ([]logfile.Meta, string) {
src, what := a.rows(), "filtered"
if a.pins.Len() > 0 {
src, what = a.pins.Sorted(), "pinned"
}
metas := a.idx.Metas()
out := make([]logfile.Meta, 0, len(src))
for _, i := range src {
if int(i) < len(metas) {
out = append(out, metas[i])
}
}
return out, what
}
func (a *App) runExport(path string) {
if path == "" || a.idx == nil {
return
}
if filepath.Ext(path) == "" {
path += export.JSONL{}.Ext()
}
set, what := a.exportSet()
if len(set) == 0 {
a.say(tui.ToastWarning, "nothing to export")
return
}
n, err := export.ToFile(path, export.JSONL{}, a.rd, set)
if err != nil {
a.say(tui.ToastError, err.Error())
return
}
abs, err := filepath.Abs(path)
if err != nil {
abs = path
}
a.say(tui.ToastSuccess, fmt.Sprintf("%d %s → %s", n, what, abs))
}
// defaultExportName is timestamped: exports are exclusive-create, so a fixed
// name would collide on the second export.
func defaultExportName(src string) string {
base := strings.TrimSuffix(filepath.Base(src), filepath.Ext(src))
if base == "" || base == "." {
base = "vif-log"
}
return base + "-" + time.Now().Format("150405") + ".jsonl"
}