v0.1.0 initial commit
This commit is contained in:
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user