551 lines
12 KiB
Go
551 lines
12 KiB
Go
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"
|
|
}
|