Files
2026-08-06 10:18:22 -04:00

433 lines
11 KiB
Go

package app
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/lixenwraith/terminal"
"github.com/lixenwraith/terminal/tui"
"github.com/lixenwraith/vif-log/internal/filter"
"github.com/lixenwraith/vif-log/internal/keys"
"github.com/lixenwraith/vif-log/internal/logfile"
"github.com/lixenwraith/vif-log/internal/ui"
)
const (
firstPassBudget = 40 * time.Millisecond // filter time spent on a filter change
stepBudget = 8 * time.Millisecond // filter time spent per tick
budgetCheck = 2048 // records tested between deadline checks
followScanCap = 1 << 21
toastFrames = 40
minW = 80
minH = 24
)
type overlayKind uint8
const (
ovNone overlayKind = iota
ovHelp
ovOpen
ovPrompt
)
// viewBuilder tracks the incremental filter pass. A filter change resets it;
// index growth extends it.
type viewBuilder struct {
next int
busy bool
}
// App holds all viewer state.
type App struct {
term terminal.Terminal
th ui.Theme
lay ui.Layout
res *keys.Resolver
idx *logfile.Index
rd *logfile.Reader // render path
frd *logfile.Reader // filter pass; disjoint window, no thrash
title string
stack filter.Stack
lvl *filter.Level
snap *filter.Collapse
find *filter.Find
pins *filter.PinSet
pinOnly *filter.Pinned
build viewBuilder
view []int32 // record indices, always ascending
order []int32 // display order when sorted
col logfile.Column
sortCol logfile.Column
sortDir sortDir
cursor int
scroll int
dscroll int
listH int
overlay overlayKind
prompt *tui.TextFieldState
promptKind promptKind
browse browser
help ui.Panel
helpLines []helpLine
toast tui.ToastState
scanShown bool
w, h int
frame int
quit bool
cells []terminal.Cell // reused frame buffer
rec logfile.Record
fctx filter.Ctx
}
// New builds the viewer. start may be log files, one directory to browse, or
// empty for the working directory. specs are "kind:arg" filters applied up front.
func New(t terminal.Terminal, start []string, specs []string) (*App, error) {
w, h := t.Size()
a := &App{
term: t, th: ui.DefaultTheme, lay: ui.DefaultLayout(), res: keys.NewResolver(),
w: w, h: h, col: logfile.ColAll, sortCol: logfile.ColTime,
helpLines: buildHelpLines(),
}
a.help = ui.Panel{W: 70}
a.browse.panel.Cursor = true
// Snapshots collapse by default: stat members are ~half the records in a
// typical log and arrive in blocks of ~40. enter expands.
a.lvl, a.snap, a.find = filter.NewLevel(), filter.NewCollapse(true), filter.NewFind()
a.pins = filter.NewPinSet()
a.pinOnly = filter.NewPinned(a.pins)
a.stack.Add(a.lvl)
a.stack.Add(a.snap)
a.stack.Add(a.pinOnly)
a.stack.Add(a.find)
for _, s := range specs {
if err := a.applyFilterSpec(s); err != nil {
return nil, err
}
}
a.initStart(start)
return a, nil
}
// applyFilterSpec parses "kind:arg" and installs it. Kinds owned by a key
// binding are routed to their owner so the app keeps a single instance.
func (a *App) applyFilterSpec(spec string) error {
kind, arg, ok := strings.Cut(spec, ":")
if !ok {
return fmt.Errorf("filter: expected kind:arg, got %q", spec)
}
kind = strings.TrimSpace(kind)
switch kind {
case "level":
f, err := filter.New(kind, arg)
if err != nil {
return err
}
a.lvl.Mask = f.(*filter.Level).Mask
return nil
case "find":
return a.find.Set(arg, a.col)
case "snap", "pin":
return fmt.Errorf("filter: %s is bound to a key, not a spec", kind)
}
if strings.TrimSpace(arg) == "" {
a.stack.Remove(kind)
return nil
}
f, err := filter.New(kind, arg)
if err != nil {
return err
}
a.stack.Set(f)
return nil
}
// initStart loads the named files, or opens the browser at the right directory.
func (a *App) initStart(start []string) {
dir := "."
if len(start) == 1 {
if fi, err := os.Stat(start[0]); err == nil && fi.IsDir() {
a.browse.dir = start[0]
a.openBrowser()
return
}
}
if len(start) > 0 {
a.browse.dir = filepath.Dir(start[0])
if err := a.openFiles(start); err == nil {
return
} else {
a.say(tui.ToastError, err.Error())
}
}
if a.browse.dir == "" {
a.browse.dir = dir
}
a.openBrowser()
}
// openFiles replaces the loaded set with one merged index. Level, search, sort
// and stack filters survive; pins, snapshot exceptions and the cursor reset.
func (a *App) openFiles(paths []string) error {
idx, err := logfile.Open(paths...)
if err != nil {
return err
}
rd, err := idx.NewReader()
if err != nil {
return err
}
frd, err := idx.NewReader()
if err != nil {
rd.Close()
return err
}
if a.rd != nil {
a.rd.Close()
}
if a.frd != nil {
a.frd.Close()
}
a.idx, a.rd, a.frd = idx, rd, frd
a.title = idx.SrcName(0)
if n := idx.SrcCount(); n > 1 {
a.title = fmt.Sprintf("%s +%d", a.title, n-1)
}
a.pins.Clear()
a.pinOnly.On = false
a.snap.ResetGroups()
a.view, a.order = a.view[:0], a.order[:0]
a.cursor, a.scroll, a.dscroll = 0, 0, 0
a.build = viewBuilder{}
a.scanShown = false
a.rebuild()
a.say(tui.ToastSuccess, fmt.Sprintf("opened %d file(s)", idx.SrcCount()))
return nil
}
// Close releases the line readers.
func (a *App) Close() {
if a.rd != nil {
a.rd.Close()
}
if a.frd != nil {
a.frd.Close()
}
}
// Quit reports whether the event loop should stop.
func (a *App) Quit() bool { return a.quit }
// --- event handling --------------------------------------------------------
// Handle processes one terminal event.
func (a *App) Handle(ev terminal.Event) {
switch ev.Type {
case terminal.EventResize:
a.w, a.h = ev.Width, ev.Height
return
case terminal.EventError, terminal.EventClosed:
a.quit = true
return
case terminal.EventKey:
if ev.Key == terminal.KeyNone { // synthetic tick
a.tick()
return
}
default:
return
}
if a.overlay == ovPrompt {
a.handlePrompt(ev)
return
}
mode := keys.ModeNormal
if a.overlay != ovNone {
mode = keys.ModeOverlay
}
act := a.res.Resolve(mode, keys.FromEvent(ev))
if act == keys.ActNone {
return
}
table := actions
if mode == keys.ModeOverlay {
table = overlayActions
}
if fn := table[act]; fn != nil {
fn(a)
}
}
// handlePrompt routes text entry; the binding table has no place for it.
func (a *App) handlePrompt(ev terminal.Event) {
switch ev.Key {
case terminal.KeyEscape:
a.overlay = ovNone
case terminal.KeyEnter:
a.commitPrompt()
default:
a.prompt.HandleKey(ev.Key, ev.Rune, ev.Modifiers)
}
}
// tick advances animation and extends the filter pass into newly indexed rows.
func (a *App) tick() {
a.frame++
if a.toast.Visible {
a.toast.Tick()
}
if a.build.busy || a.build.next < a.idx.Len() {
a.build.busy = true
a.filterStep(stepBudget)
if !a.build.busy {
a.applySort()
}
}
if !a.scanShown {
if err := a.idx.Err(); err != nil {
a.scanShown = true
a.say(tui.ToastError, "scan: "+err.Error())
}
}
a.clamp()
}
// panel returns the scrollable body of the open overlay, nil when none is.
func (a *App) panel() *ui.Panel {
switch a.overlay {
case ovHelp:
return &a.help
case ovOpen:
return &a.browse.panel
}
return nil
}
func (a *App) toggleLevel(l logfile.Level) {
a.lvl.Toggle(l)
a.rebuild()
}
func (a *App) threshold(l logfile.Level) {
a.lvl.ThresholdToggle(l)
a.rebuild()
}
func (a *App) say(sev tui.ToastSeverity, msg string) {
o := tui.DefaultToastOpts(msg, sev)
o.Position = tui.ToastBottomRight
o.Style = tui.ToastStyleRounded
a.toast.Show(o, toastFrames)
}
// actions maps key actions to behaviour; keys knows nothing about App.
var actions = map[keys.Action]func(*App){
keys.ActQuit: func(a *App) { a.quit = true },
keys.ActRedraw: func(a *App) { a.term.Sync() },
keys.ActHelp: func(a *App) { a.overlay = ovHelp },
keys.ActCloseOverlay: func(a *App) { a.overlay = ovNone },
keys.ActDown: func(a *App) { a.move(1) },
keys.ActUp: func(a *App) { a.move(-1) },
keys.ActPageDown: func(a *App) { a.move(max(1, a.listH)) },
keys.ActPageUp: func(a *App) { a.move(-max(1, a.listH)) },
keys.ActHalfDown: func(a *App) { a.move(tui.PageDelta(a.listH)) },
keys.ActHalfUp: func(a *App) { a.move(-tui.PageDelta(a.listH)) },
keys.ActTop: func(a *App) { a.cursor, a.dscroll = 0, 0; a.clamp() },
keys.ActBottom: func(a *App) { a.cursor, a.dscroll = len(a.rows())-1, 0; a.clamp() },
keys.ActColNext: func(a *App) { a.cycleColumn(1) },
keys.ActColPrev: func(a *App) { a.cycleColumn(-1) },
keys.ActSort: (*App).cycleSort,
keys.ActDetailDown: func(a *App) { a.dscroll++ },
keys.ActDetailUp: func(a *App) { a.dscroll = max(0, a.dscroll-1) },
keys.ActSearch: (*App).openSearch,
keys.ActFollowNext: func(a *App) { a.followJump(1) },
keys.ActFollowPrev: func(a *App) { a.followJump(-1) },
keys.ActFilter: (*App).openFilter,
keys.ActClear: (*App).clearState,
keys.ActExpand: (*App).toggleSnapshot,
keys.ActSnapNext: (*App).nextSnapshot,
keys.ActSnapPrev: (*App).prevSnapshot,
keys.ActPinToggle: (*App).togglePin,
keys.ActPinOnly: (*App).togglePinOnly,
keys.ActPinClear: (*App).clearPins,
keys.ActOpen: (*App).openBrowser,
keys.ActExport: (*App).openExport,
keys.ActLvlTrace: func(a *App) { a.toggleLevel(logfile.LevelTrace) },
keys.ActLvlDebug: func(a *App) { a.toggleLevel(logfile.LevelDebug) },
keys.ActLvlInfo: func(a *App) { a.toggleLevel(logfile.LevelInfo) },
keys.ActLvlWarn: func(a *App) { a.toggleLevel(logfile.LevelWarn) },
keys.ActLvlError: func(a *App) { a.toggleLevel(logfile.LevelError) },
keys.ActLvlProc: func(a *App) { a.toggleLevel(logfile.LevelProc) },
keys.ActLvlBad: func(a *App) { a.toggleLevel(logfile.LevelBad) },
keys.ActLvlAll: func(a *App) { a.lvl.SetAll(true); a.rebuild() },
keys.ActLvlRaise: func(a *App) { a.lvl.Shift(1); a.rebuild() },
keys.ActLvlLower: func(a *App) { a.lvl.Shift(-1); a.rebuild() },
keys.ActThresh1: func(a *App) { a.threshold(logfile.LevelTrace) },
keys.ActThresh2: func(a *App) { a.threshold(logfile.LevelDebug) },
keys.ActThresh3: func(a *App) { a.threshold(logfile.LevelInfo) },
keys.ActThresh4: func(a *App) { a.threshold(logfile.LevelWarn) },
keys.ActThresh5: func(a *App) { a.threshold(logfile.LevelError) },
}
// overlayActions drive the focused panel; movement keys are shared with the
// record list and resolve here only while an overlay is open.
var overlayActions = map[keys.Action]func(*App){
keys.ActCloseOverlay: func(a *App) { a.overlay = ovNone },
keys.ActQuit: func(a *App) { a.overlay = ovNone },
keys.ActDown: func(a *App) { a.panelDo(func(p *ui.Panel) { p.Move(1) }) },
keys.ActUp: func(a *App) { a.panelDo(func(p *ui.Panel) { p.Move(-1) }) },
keys.ActPageDown: func(a *App) { a.panelDo(func(p *ui.Panel) { p.Page(1) }) },
keys.ActPageUp: func(a *App) { a.panelDo(func(p *ui.Panel) { p.Page(-1) }) },
keys.ActHalfDown: func(a *App) { a.panelDo(func(p *ui.Panel) { p.Half(1) }) },
keys.ActHalfUp: func(a *App) { a.panelDo(func(p *ui.Panel) { p.Half(-1) }) },
keys.ActTop: func(a *App) { a.panelDo((*ui.Panel).First) },
keys.ActBottom: func(a *App) { a.panelDo((*ui.Panel).Last) },
keys.ActMark: func(a *App) {
if a.overlay == ovOpen {
a.browse.toggleMark()
}
},
keys.ActConfirm: func(a *App) {
if a.overlay == ovOpen {
a.browserEnter()
}
},
keys.ActBack: func(a *App) {
if a.overlay == ovOpen {
a.browserUp()
}
},
}
func (a *App) panelDo(fn func(*ui.Panel)) {
if p := a.panel(); p != nil {
fn(p)
}
}