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
+432
View File
@@ -0,0 +1,432 @@
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)
}
}
+223
View File
@@ -0,0 +1,223 @@
package app
import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"time"
"github.com/lixenwraith/terminal"
"github.com/lixenwraith/terminal/tui"
"github.com/lixenwraith/vif-log/internal/ui"
)
// fileRow is one entry in the open panel.
type fileRow struct {
name string
dir bool
up bool
marked bool
size int64
mtime time.Time
}
// browser is the directory navigator behind the open overlay.
type browser struct {
dir string
rows []fileRow
panel ui.Panel
err string
}
// load reads dir: parent first, then directories, then files, each group most
// recently modified first — the log you just produced is the one you want.
func (b *browser) load(dir string) {
if abs, err := filepath.Abs(dir); err == nil {
dir = abs
}
b.dir, b.err, b.rows = dir, "", b.rows[:0]
des, err := os.ReadDir(dir)
if err != nil {
b.err = err.Error()
}
if parent := filepath.Dir(dir); parent != dir {
b.rows = append(b.rows, fileRow{name: "..", dir: true, up: true})
}
rows := make([]fileRow, 0, len(des))
for _, de := range des {
if strings.HasPrefix(de.Name(), ".") {
continue
}
fi, err := de.Info()
if err != nil {
continue
}
rows = append(rows, fileRow{
name: de.Name(), dir: de.IsDir(), size: fi.Size(), mtime: fi.ModTime(),
})
}
slices.SortFunc(rows, func(x, y fileRow) int {
if x.dir != y.dir {
if x.dir {
return -1
}
return 1
}
return y.mtime.Compare(x.mtime)
})
b.rows = append(b.rows, rows...)
b.panel.Reset()
}
func (b *browser) selected() (fileRow, bool) {
if b.panel.Sel < 0 || b.panel.Sel >= len(b.rows) {
return fileRow{}, false
}
return b.rows[b.panel.Sel], true
}
// toggleMark selects a file for multi-open; directories are not markable.
func (b *browser) toggleMark() {
if b.panel.Sel < 0 || b.panel.Sel >= len(b.rows) || b.rows[b.panel.Sel].dir {
return
}
b.rows[b.panel.Sel].marked = !b.rows[b.panel.Sel].marked
b.panel.Move(1)
}
// marked returns the absolute paths of every marked file.
func (b *browser) marked() []string {
var out []string
for _, r := range b.rows {
if r.marked {
out = append(out, filepath.Join(b.dir, r.name))
}
}
return out
}
// --- app hooks -------------------------------------------------------------
// openBrowser shows the file panel, refreshing the listing.
func (a *App) openBrowser() {
dir := a.browse.dir
if dir == "" {
dir = "."
}
a.browse.load(dir)
a.overlay = ovOpen
}
// browserEnter descends into the selected directory, or loads the marked set
// (falling back to the cursor file when nothing is marked).
func (a *App) browserEnter() {
if paths := a.browse.marked(); len(paths) > 0 {
if err := a.openFiles(paths); err != nil {
a.say(tui.ToastError, err.Error())
return
}
a.overlay = ovNone
return
}
row, ok := a.browse.selected()
if !ok {
return
}
path := filepath.Join(a.browse.dir, row.name)
if row.dir {
a.browse.load(path)
return
}
if err := a.openFiles([]string{path}); err != nil {
a.say(tui.ToastError, err.Error())
return
}
a.overlay = ovNone
}
func (a *App) browserUp() { a.browse.load(filepath.Dir(a.browse.dir)) }
// --- render ----------------------------------------------------------------
func (a *App) renderBrowser(root tui.Region) {
b := &a.browse
b.panel.Rows = len(b.rows)
b.panel.Title = "open - " + tui.TruncateLeft(b.dir, max(12, root.W/3))
b.panel.Hint = "spc mark enter open esc close"
b.panel.Status = b.status()
body := b.panel.Render(root, &a.th)
if body.W < 24 || body.H < 1 {
return
}
const sizeW, timeW = 7, 16
nameW := max(8, body.W-sizeW-timeW-2)
for y := 0; y < body.H; y++ {
i := b.panel.Scroll + y
if i >= len(b.rows) {
break
}
row := b.rows[i]
bg := a.th.FocusBg
if i == b.panel.Sel {
bg = a.th.CursorBg
}
for x := 0; x < body.W; x++ {
body.Cell(x, y, ' ', a.th.Fg, bg, terminal.AttrNone)
}
name, fg, attr := row.name, a.th.Fg, terminal.AttrNone
if row.dir {
name, fg, attr = name+"/", a.th.Accent, terminal.AttrBold
}
if row.marked {
name, fg, attr = "◆ "+name, a.th.Selected, terminal.AttrBold
}
body.Text(0, y, tui.Truncate(name, nameW), fg, bg, attr)
if !row.dir {
body.Text(nameW+1, y, tui.PadLeft(humanSize(row.size), sizeW),
a.th.NumFg, bg, terminal.AttrNone)
}
if !row.up {
body.Text(nameW+sizeW+2, y, row.mtime.Format("2006-01-02 15:04"),
a.th.HintFg, bg, terminal.AttrDim)
}
}
}
// status is the untruncated name of the selection, or the read error.
func (b *browser) status() string {
if b.err != "" {
return b.err
}
if row, ok := b.selected(); ok {
return row.name
}
return b.dir
}
// humanSize renders a byte count in at most six characters.
func humanSize(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%dB", n)
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
v := float64(n) / float64(div)
if v < 10 {
return fmt.Sprintf("%.1f%c", v, "KMGTPE"[exp])
}
return fmt.Sprintf("%.0f%c", v, "KMGTPE"[exp])
}
+670
View File
@@ -0,0 +1,670 @@
package app
import (
"fmt"
"strconv"
"strings"
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
"github.com/lixenwraith/terminal/tui"
"github.com/lixenwraith/vif-log/internal/keys"
"github.com/lixenwraith/vif-log/internal/logfile"
"github.com/lixenwraith/vif-log/internal/ui"
)
// paneOrder fixes render order. The list sizes itself first so the status bar
// reads that height in the same frame.
var paneOrder = []ui.Pane{
ui.PaneList, ui.PaneDetail, ui.PaneHeader, ui.PaneStatus, ui.PaneFooter,
}
// renderers binds panes to draw functions; layout decides where they land.
var renderers = map[ui.Pane]func(*App, tui.Region){
ui.PaneHeader: (*App).renderHeader,
ui.PaneList: (*App).renderList,
ui.PaneDetail: (*App).renderDetail,
ui.PaneStatus: (*App).renderStatus,
ui.PaneFooter: (*App).renderFooter,
}
// Render draws one frame.
func (a *App) Render() {
w, h := a.w, a.h
if w < 4 || h < 2 {
return
}
if len(a.cells) != w*h {
a.cells = make([]terminal.Cell, w*h)
}
blank := terminal.Cell{Rune: ' ', Fg: a.th.Fg, Bg: a.th.Bg}
for i := range a.cells {
a.cells[i] = blank
}
cells := a.cells
root := tui.NewRegion(cells, w, 0, 0, w, h)
if w < minW || h < minH {
a.renderTooSmall(root)
a.term.Flush(cells, w, h)
return
}
regions := a.lay.Resolve(root)
for _, pane := range paneOrder {
if fn, region := renderers[pane], regions[pane]; fn != nil && region.W > 0 {
fn(a, region)
}
}
switch a.overlay {
case ovPrompt:
a.renderPrompt(regions[ui.PaneFooter])
case ovHelp:
a.renderHelp(root)
case ovOpen:
a.renderBrowser(root)
}
if a.toast.Visible {
root.Toast(a.toast.Opts)
}
a.term.Flush(cells, w, h)
}
// renderTooSmall reports the shortfall rather than degrading the layout.
func (a *App) renderTooSmall(r tui.Region) {
r.Fill(a.th.Bg)
y := r.H / 2
r.TextCenter(y-1, "terminal too small", a.th.Warning, a.th.Bg, terminal.AttrBold)
r.TextCenter(y+1, fmt.Sprintf("%dx%d - need %dx%d", a.w, a.h, minW, minH),
a.th.HintFg, a.th.Bg, terminal.AttrNone)
r.TextCenter(y+3, "q quits", a.th.HintFg, a.th.Bg, terminal.AttrDim)
}
// --- bars ------------------------------------------------------------------
// pair is a label/value cell in the header and status bars.
type pair struct {
label, value string
fg color.RGB
}
const pairSep = " · "
const pairSepW = 3 // runes; len(pairSep) is 4 bytes
// drawPairsRight renders pairs right-aligned, dropping leading pairs that do
// not fit. Returns the x where drawing started, so the caller can clip its own
// left-aligned content. Unlike tui.StatusBar this does not clear the row.
func (a *App) drawPairsRight(r tui.Region, y int, ps []pair, bg color.RGB) int {
width := func(p pair) int { return tui.RuneLen(p.label) + 1 + tui.RuneLen(p.value) }
total := 0
for i, p := range ps {
total += width(p)
if i > 0 {
total += pairSepW
}
}
for len(ps) > 1 && total > r.W-2 {
total -= width(ps[0]) + pairSepW
ps = ps[1:]
}
x := max(0, r.W-total-1)
start := x
for i, p := range ps {
if i > 0 {
r.Text(x, y, pairSep, a.th.HintFg, bg, terminal.AttrDim)
x += pairSepW
}
r.Text(x, y, p.label, a.th.HintFg, bg, terminal.AttrNone)
x += tui.RuneLen(p.label) + 1
r.Text(x, y, p.value, p.fg, bg, terminal.AttrBold)
x += tui.RuneLen(p.value)
}
return start
}
func (a *App) renderHeader(r tui.Region) {
r.Fill(a.th.HeaderBg)
rx := a.drawPairsRight(r, 0, a.headerPairs(), a.th.HeaderBg)
x := 1
r.Text(x, 0, " vif-log ", a.th.Bg, a.th.Accent, terminal.AttrBold)
x += 10
if rx > x+2*int(logfile.LevelCount) {
x = a.drawLevelStrip(r, x)
}
if w := rx - x - 2; w > 8 {
// title carries "name +N" once several files are merged
name := a.title
if name == "" {
name = "no file - press o to open"
}
r.Text(x+1, 0, tui.TruncateLeft(name, w), a.th.HeaderFg, a.th.HeaderBg, terminal.AttrBold)
}
}
// drawLevelStrip shows every level's initial, filled for the levels currently
// shown and outlined for the hidden ones. Returns the x past the strip.
func (a *App) drawLevelStrip(r tui.Region, x int) int {
for l := logfile.Level(0); l < logfile.LevelCount; l++ {
fg, bg, attr := a.th.Level[l], a.th.HeaderBg, terminal.AttrNone
if a.lvl.Has(l) {
fg, bg, attr = a.th.HeaderBg, a.th.Level[l], terminal.AttrBold
}
r.Cell(x, 0, rune(l.Initial()), fg, bg, attr)
x += 2
}
return x
}
func (a *App) headerPairs() []pair {
ps := make([]pair, 0, 4)
if scanned, total, done := a.idx.Progress(); !done {
p := 0
if total > 0 {
p = int(scanned * 100 / total)
}
ps = append(ps, pair{"indexing", fmt.Sprintf("%d%%", p), a.th.Accent2})
}
n := len(a.rows())
row := "0"
if n > 0 {
row = fmt.Sprint(a.cursor + 1)
}
return append(ps,
pair{"row", row, a.th.HeaderFg},
pair{"shown", fmt.Sprint(n), a.th.Selected},
pair{"total", fmt.Sprint(a.idx.Len()), a.th.StatusFg},
)
}
func (a *App) renderStatus(r tui.Region) {
r.Fill(a.th.HeaderBg)
rx := a.drawPairsRight(r, 0, a.statusPairs(), a.th.HeaderBg)
if s := strings.Join(a.stack.Summary(), " "); s != "" && rx > 3 {
r.Text(1, 0, tui.Truncate(s, rx-2), a.th.Accent, a.th.HeaderBg, terminal.AttrBold)
}
}
// statusPairs is ordered least to most important: overflow drops from the left.
func (a *App) statusPairs() []pair {
ps := make([]pair, 0, 5)
if a.build.busy {
ps = append(ps, pair{"filtering",
fmt.Sprintf("%d%%", pct(a.build.next, a.idx.Len())), a.th.Accent2})
}
if n := a.idx.Malformed(); n > 0 {
ps = append(ps, pair{"bad", fmt.Sprint(n), a.th.Error})
}
if n := a.pins.Len(); n > 0 {
ps = append(ps, pair{"pins", fmt.Sprint(n), a.th.Warning})
}
if a.sortDir != sortNone {
ps = append(ps, pair{"sort", a.sortCol.String() + " " + a.sortDir.String(), a.th.Partial})
}
return append(ps, pair{"col", a.col.String(), a.th.Accent})
}
func pct(n, total int) int {
if total <= 0 {
return 100
}
return n * 100 / total
}
// --- record list -----------------------------------------------------------
// colLayout is the physical geometry of the list pane: pin gutter, optional
// source gutter, then the focusable columns with level wedged after time.
type colLayout struct {
w int
pinX int
srcX, srcW int
tsX, tsW int
lvlX int
tickX, tickW int
subX, subW int
markX int
msgX, msgW int
fldX int
}
func listCols(w, nsrc int) colLayout {
c := colLayout{w: w, tsW: 12, subW: 8, msgW: 16}
if nsrc > 1 {
c.srcW = 1
}
if w >= 96 {
c.tickW = 6
}
if w < 76 {
c.msgW = 12
}
if w < 60 {
c.subW, c.msgW = 6, 10
}
x := 0
c.pinX, x = x, x+2
c.srcX = x
if c.srcW > 0 {
x += c.srcW + 1
}
c.tsX, x = x, x+c.tsW+1
c.lvlX, x = x, x+2
c.tickX = x
if c.tickW > 0 {
x += c.tickW + 1
}
c.subX, x = x, x+c.subW+1
c.markX, x = x, x+2
c.msgX, x = x, x+c.msgW+1
c.fldX = x
return c
}
// span returns the header extent of a focusable column.
func (c colLayout) span(col logfile.Column) (int, int) {
switch col {
case logfile.ColTime:
return c.tsX, c.tsW
case logfile.ColTick:
return c.tickX, c.tickW
case logfile.ColSub:
return c.subX, c.subW
case logfile.ColMsg:
return c.msgX, c.msgW
case logfile.ColFields:
return c.fldX, max(0, c.w-c.fldX)
}
return 0, c.w
}
func (a *App) renderList(r tui.Region) {
r.Fill(a.th.Bg)
list := r.Sub(0, 0, r.W-1, r.H)
c := listCols(list.W, a.idx.SrcCount())
a.renderColHeader(list, c)
body := list.Sub(0, 1, list.W, list.H-1)
a.listH = body.H
a.clamp()
rows := a.rows()
if len(rows) == 0 {
msg := "no records match"
if a.idx == nil {
msg = "no file open — press o"
}
body.TextCenter(body.H/2, msg, a.th.HintFg, a.th.Bg, terminal.AttrDim)
} else {
metas := a.idx.Metas()
for y := 0; y < body.H; y++ {
i := a.scroll + y
if i < 0 || i >= len(rows) {
break
}
rec := rows[i]
if int(rec) >= len(metas) {
break
}
a.renderRow(body, y, rec, metas[rec], c, i == a.cursor)
}
}
sb := r.Sub(r.W-1, 1, 1, r.H-1)
sb.ScrollBar(0, a.scroll, sb.H, len(rows), a.th.Border)
}
// renderColHeader names the columns and marks the focused one, which is both
// the search scope and the sort target.
func (a *App) renderColHeader(r tui.Region, c colLayout) {
for x := 0; x < r.W; x++ {
r.Cell(x, 0, ' ', a.th.HintFg, a.th.HeaderBg, terminal.AttrNone)
}
fx, fw := c.span(a.col)
for x := fx; x < fx+fw && x < r.W; x++ {
r.Cell(x, 0, ' ', a.th.Bg, a.th.Accent, terminal.AttrNone)
}
head := func(x, w int, s string, focused bool) {
fg, bg := a.th.HintFg, a.th.HeaderBg
if focused {
fg, bg = a.th.Bg, a.th.Accent
}
r.Text(x, 0, tui.Truncate(s, max(1, w)), fg, bg, terminal.AttrBold)
}
all := a.col == logfile.ColAll
head(c.tsX, c.tsW, "time", all || a.col == logfile.ColTime)
head(c.lvlX, 1, "T", all)
if c.tickW > 0 {
head(c.tickX, c.tickW, "tick", all || a.col == logfile.ColTick)
}
head(c.subX, c.subW, "sub", all || a.col == logfile.ColSub)
head(c.msgX, c.msgW, "msg", all || a.col == logfile.ColMsg)
head(c.fldX, max(1, r.W-c.fldX), "fields", all || a.col == logfile.ColFields)
if a.sortDir != sortNone && sortable(a.sortCol) {
sx, sw := c.span(a.sortCol)
if x := sx + sw - 1; sw > 0 && x < r.W {
fg, bg := a.th.Accent, a.th.HeaderBg
if all || a.sortCol == a.col {
fg, bg = a.th.Bg, a.th.Accent
}
r.Cell(x, 0, a.sortDir.arrow(), fg, bg, terminal.AttrBold)
}
}
}
func (a *App) renderRow(r tui.Region, y int, rec int32, m logfile.Meta, c colLayout, cursor bool) {
pinned := a.pins.Has(rec)
bg := a.th.Bg
switch {
case cursor:
bg = a.th.CursorBg
case pinned:
bg = a.th.FocusBg
}
for x := 0; x < r.W; x++ {
r.Cell(x, y, ' ', a.th.Fg, bg, terminal.AttrNone)
}
if pinned {
r.Cell(c.pinX, y, '◆', a.th.Warning, bg, terminal.AttrBold)
}
collapsed := m.Flags&logfile.FlagSnapHead != 0 && !a.snap.Expanded(m.Snap)
// Source gutter: present only when more than one file is indexed
if c.srcW > 0 {
r.Cell(c.srcX, y, a.idx.SrcMark(m.Src), a.th.SrcColor(int(m.Src)), bg, terminal.AttrBold)
}
r.Text(c.tsX, y, tui.Truncate(logfile.StampText(m), c.tsW), a.th.HintFg, bg, terminal.AttrDim)
lvlFg := a.th.Level[logfile.LevelBad]
if m.Lvl < logfile.LevelCount {
lvlFg = a.th.Level[m.Lvl]
}
r.Cell(c.lvlX, y, rune(m.Lvl.Initial()), lvlFg, bg, terminal.AttrBold)
// Tick column appears only on wide terminals; the index carries it always
if c.tickW > 0 {
r.Text(c.tickX, y, tui.PadLeft(strconv.FormatUint(uint64(m.Tick), 10), c.tickW),
a.th.HintFg, bg, terminal.AttrDim)
}
sub := logfile.Dash(a.idx.SubName(m.Sub))
r.Text(c.subX, y, tui.PadRight(tui.Truncate(sub, c.subW), c.subW), a.th.SubColor(sub), bg, terminal.AttrNone)
if mark := snapMark(m, collapsed); mark != 0 {
r.Cell(c.markX, y, mark, a.th.SnapFg, bg, terminal.AttrNone)
}
msg, msgAttr := logfile.Dash(a.idx.MsgName(m.Msg)), terminal.AttrNone
if collapsed {
msg, msgAttr = "snapshot", terminal.AttrBold
}
r.Text(c.msgX, y, tui.PadRight(tui.Truncate(msg, c.msgW), c.msgW), a.th.Fg, bg, msgAttr)
if c.fldX >= r.W {
return
}
switch {
case m.Flags&logfile.FlagMalformed != 0:
r.Text(c.fldX, y, tui.Truncate("<malformed line>", r.W-c.fldX),
a.th.Level[logfile.LevelBad], bg, terminal.AttrItalic)
case collapsed:
r.Text(c.fldX, y, tui.Truncate(a.snapSummary(m), r.W-c.fldX), a.th.SnapFg, bg, terminal.AttrNone)
default:
r.Text(c.fldX, y, tui.Truncate(a.summary(m), r.W-c.fldX), a.th.StatusFg, bg, terminal.AttrNone)
}
}
// snapMark returns the group indicator: collapsed head, expanded head, member.
func snapMark(m logfile.Meta, collapsed bool) rune {
switch {
case m.Flags&logfile.FlagSnapHead != 0 && collapsed:
return '▶'
case m.Flags&logfile.FlagSnapHead != 0:
return '▼'
case m.Snap != 0:
return '·'
}
return 0
}
// snapSummary describes a collapsed group from the index alone, no line read.
func (a *App) snapSummary(m logfile.Meta) string {
if s, ok := a.idx.SnapshotOf(m); ok {
return fmt.Sprintf("stat snapshot ×%d run=%d tick=%d", s.Count, s.Run, s.Tick)
}
return fmt.Sprintf("stat snapshot run=%d tick=%d", m.Run, m.Tick)
}
// summary renders the fields column; the search filter matches this same text.
func (a *App) summary(m logfile.Meta) string {
line, err := a.rd.Line(m)
if err != nil {
return ""
}
a.rec.Parse(m, line)
return a.rec.FieldsText()
}
// --- detail ----------------------------------------------------------------
type detailRow struct {
k, v string
head bool
}
func (a *App) renderDetail(r tui.Region) {
r.Fill(a.th.Bg)
r.VLine(0, tui.LineSingle, a.th.Border)
c := r.Sub(2, 0, r.W-2, r.H)
if c.W < 8 || c.H < 2 {
return
}
rec := a.cursorRec()
m, ok := a.meta(rec)
if !ok {
c.TextCenter(0, "no record", a.th.HintFg, a.th.Bg, terminal.AttrDim)
return
}
line, err := a.rd.Line(m)
if err != nil {
c.Text(0, 0, "read: "+err.Error(), a.th.Error, a.th.Bg, terminal.AttrNone)
return
}
a.rec.Parse(m, line)
rows := make([]detailRow, 0, 10+len(a.rec.Fields))
appendWrapped := func(key, val string, head bool) string {
if val == "" {
rows = append(rows, detailRow{key, "", head})
return " "
}
for _, vline := range strings.Split(val, "\n") {
runes := []rune(vline)
if len(runes) == 0 {
rows = append(rows, detailRow{key, "", head})
key = " "
continue
}
for len(runes) > 0 {
width := max(10, c.W-tui.RuneLen(key)-2)
chunk := runes
if len(chunk) > width {
chunk = chunk[:width]
}
rows = append(rows, detailRow{key, string(chunk), head})
runes = runes[len(chunk):]
key = " "
}
}
return key
}
appendWrapped("time", a.rec.Time, true)
appendWrapped("level", m.Lvl.String(), true)
// Source line only earns its row in a merged view
if a.idx.SrcCount() > 1 {
appendWrapped("file", a.idx.SrcName(m.Src), true)
}
appendWrapped("sub", logfile.Dash(a.idx.SubName(m.Sub)), true)
appendWrapped("run", fmt.Sprint(m.Run), true)
appendWrapped("tick", fmt.Sprint(m.Tick), true)
appendWrapped("frame", fmt.Sprint(m.Frame), true)
if a.rec.Trace != "" {
key := "trace"
steps := strings.Split(a.rec.Trace, " -> ")
for i, step := range steps {
if i > 0 {
step = "-> " + step
}
key = appendWrapped(key, step, true)
}
}
if s, ok := a.idx.SnapshotOf(m); ok {
appendWrapped("snapshot", fmt.Sprintf("%d records, head %d", s.Count, s.Head), true)
}
if a.pins.Has(rec) {
appendWrapped("pinned", "yes", true)
}
rows = append(rows, detailRow{})
if a.rec.Bad {
raw := strings.TrimRight(string(line), "\r\n")
appendWrapped("raw", raw, false)
} else {
for _, f := range a.rec.Fields {
appendWrapped(f.Key, a.rec.Display(f), false)
}
}
ks := tui.Style{Fg: a.th.KeyFg}
a.dscroll = tui.ClampScroll(a.dscroll, c.H, len(rows))
for y := 0; y < c.H; y++ {
i := a.dscroll + y
if i >= len(rows) {
break
}
if rows[i].k == "" {
c.HLine(y, tui.LineSingle, a.th.Border)
continue
}
vs := tui.Style{Fg: a.th.StrFg}
if rows[i].head {
vs = tui.Style{Fg: a.th.Fg}
}
c.KeyValue(y, rows[i].k, rows[i].v, ks, vs, ':')
}
}
// --- footer / prompt -------------------------------------------------------
// footerActions lists the bindings specific to this viewer; generic movement
// and quit keys live in the help overlay. Chords come from the key table.
var footerActions = []struct {
act keys.Action
label string
}{
{keys.ActHelp, "help"},
{keys.ActOpen, "open"},
{keys.ActSearch, "find"},
{keys.ActFilter, "filter"},
{keys.ActFollowNext, "same"},
{keys.ActExpand, "snap"},
{keys.ActPinToggle, "pin"},
{keys.ActPinOnly, "only"},
{keys.ActExport, "export"},
{keys.ActColNext, "col"},
{keys.ActSort, "sort"},
}
func (a *App) renderFooter(r tui.Region) {
r.Fill(a.th.HeaderBg)
x := 1
for _, h := range footerActions {
k := " " + keys.KeysFor(keys.ModeNormal, h.act) + " "
l := h.label + " "
if x+tui.RuneLen(k)+tui.RuneLen(l) > r.W {
break
}
r.Text(x, 0, k, a.th.Bg, a.th.Accent, terminal.AttrBold)
x += tui.RuneLen(k)
r.Text(x, 0, l, a.th.HintFg, a.th.HeaderBg, terminal.AttrNone)
x += tui.RuneLen(l)
}
}
// renderPrompt replaces the footer row while text is being entered.
func (a *App) renderPrompt(r tui.Region) {
if r.W < 5 || r.H < 1 || a.prompt == nil {
return
}
st := tui.DefaultTextFieldStyle()
st.TextBg, st.PrefixFg, st.TextFg = a.th.InputBg, a.th.Accent, a.th.Fg
st.CursorBg, st.CursorFg = a.th.Fg, a.th.Bg
r.TextField(a.prompt, tui.TextFieldOpts{
Prefix: a.promptKind.prefix(a.col), Border: tui.LineNone,
Focused: true, Style: st,
})
}
// --- help ------------------------------------------------------------------
// helpLine is one rendered row of the help panel: a group heading or a binding.
type helpLine struct {
keys, text string
head bool
}
// buildHelpLines flattens the key table once at startup.
func buildHelpLines() []helpLine {
var out []helpLine
group := ""
for _, m := range []keys.Mode{keys.ModeNormal, keys.ModeOverlay} {
for _, row := range keys.HelpRows(m) {
if row.Group != group {
group = row.Group
if len(out) > 0 {
out = append(out, helpLine{})
}
out = append(out, helpLine{text: strings.ToUpper(group), head: true})
}
out = append(out, helpLine{keys: row.Keys, text: row.Help})
}
}
return out
}
func (a *App) renderHelp(root tui.Region) {
a.help.Rows = len(a.helpLines)
a.help.Title = "vif-log keys"
a.help.Hint = "↑↓ scroll esc close"
body := a.help.Render(root, &a.th)
if body.W < 10 || body.H < 1 {
return
}
ks := tui.Style{Fg: a.th.Accent, Bg: a.th.FocusBg, Attr: terminal.AttrBold}
vs := tui.Style{Fg: a.th.Fg, Bg: a.th.FocusBg}
for y := 0; y < body.H; y++ {
i := a.help.Scroll + y
if i >= len(a.helpLines) {
break
}
l := a.helpLines[i]
switch {
case l.head:
body.Text(1, y, l.text, a.th.Accent2, a.th.FocusBg, terminal.AttrBold)
case l.text != "":
body.KeyValue(y, l.keys, l.text, ks, vs, ' ')
}
}
}
+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"
}
+55
View File
@@ -0,0 +1,55 @@
package export
import (
"bufio"
"io"
"os"
"github.com/lixenwraith/vif-log/internal/logfile"
)
// Exporter serialises a record set. JSONL is the only implementation; a plain
// text renderer is the second.
type Exporter interface {
Ext() string
Write(w io.Writer, rd *logfile.Reader, metas []logfile.Meta) (int, error)
}
// JSONL writes the verbatim source lines, so an export reopens in the viewer.
type JSONL struct{}
func (JSONL) Ext() string { return ".jsonl" }
func (JSONL) Write(w io.Writer, rd *logfile.Reader, metas []logfile.Meta) (int, error) {
bw := bufio.NewWriterSize(w, 1<<16)
n := 0
for _, m := range metas {
line, err := rd.Line(m)
if err != nil {
return n, err
}
if _, err := bw.Write(line); err != nil {
return n, err
}
if err := bw.WriteByte('\n'); err != nil {
return n, err
}
n++
}
return n, bw.Flush()
}
// ToFile writes metas to path. The file is created exclusively: an export can
// never overwrite a log.
func ToFile(path string, e Exporter, rd *logfile.Reader, metas []logfile.Meta) (int, error) {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return 0, err
}
defer f.Close()
n, err := e.Write(f, rd, metas)
if err != nil {
return n, err
}
return n, f.Sync()
}
+47
View File
@@ -0,0 +1,47 @@
package filter
import "github.com/lixenwraith/vif-log/internal/logfile"
func init() {
Register(Desc{Kind: "fields", Help: "regexp over the parsed fields, smart-case", New: newFields})
}
// Fields applies a regex to the fields column. This forces disk reads for
// surviving rows to evaluate the parsed JSON, so it evaluates last.
type Fields struct {
Query string
pat Pattern
}
func newFields(arg string) (Filter, error) {
p, err := NewPattern(arg)
if err != nil {
return nil, err
}
return &Fields{Query: arg, pat: p}, nil
}
func (f *Fields) Kind() string { return "fields" }
func (f *Fields) Needs() Need {
if f.pat.Empty() {
return 0
}
return NeedFields
}
func (f *Fields) Match(c *Ctx) bool {
if f.pat.Empty() {
return true
}
// Scopes the regex exactly to the rendered "key=value" string,
// excluding the discriminator (msg).
return f.pat.MatchBytes(c.Record().ColumnBytes(logfile.ColFields))
}
func (f *Fields) Label() string {
if f.pat.Empty() {
return ""
}
return "fields:" + f.Query
}
+198
View File
@@ -0,0 +1,198 @@
package filter
import (
"fmt"
"sort"
"github.com/lixenwraith/vif-log/internal/logfile"
)
// Need declares what a filter must touch beyond the index row.
type Need uint8
const (
NeedRaw Need = 1 << iota // raw line bytes
NeedFields // parsed fields
)
// Ctx is the per-record evaluation context. Raw bytes and parsed fields are
// fetched lazily, so index-only predicates never touch the file.
type Ctx struct {
Idx *logfile.Index
Meta logfile.Meta
I int
rd *logfile.Reader
raw []byte
rec logfile.Record
rawOK bool
recOK bool
}
// Bind attaches the index and the line reader used for raw access.
func (c *Ctx) Bind(idx *logfile.Index, rd *logfile.Reader) { c.Idx, c.rd = idx, rd }
// Reset points the context at record i.
func (c *Ctx) Reset(i int, m logfile.Meta) {
c.I, c.Meta = i, m
c.raw, c.rawOK, c.recOK = nil, false, false
}
// Raw returns the record's line bytes, nil when unavailable.
func (c *Ctx) Raw() []byte {
if !c.rawOK {
c.rawOK = true
if c.rd != nil {
c.raw, _ = c.rd.Line(c.Meta)
}
}
return c.raw
}
// Record returns the parsed record.
func (c *Ctx) Record() *logfile.Record {
if !c.recOK {
c.recOK = true
c.rec.Parse(c.Meta, c.Raw())
}
return &c.rec
}
// Filter is one predicate in the stack.
type Filter interface {
Kind() string
Label() string
Needs() Need
Match(*Ctx) bool
}
// Entry wraps a filter with the stack-level toggles, so negation and disabling
// need no per-filter code.
type Entry struct {
F Filter
Enabled bool
Negate bool
}
// Stack is the composed filter chain, evaluated cheapest-first.
type Stack struct {
Entries []Entry
order []int
needs Need
}
// Compile recomputes the evaluation order; call after mutating Entries.
func (s *Stack) Compile() {
s.order = s.order[:0]
s.needs = 0
for i, e := range s.Entries {
if !e.Enabled {
continue
}
s.order = append(s.order, i)
s.needs |= e.F.Needs()
}
// Index-only predicates first: raw readers then only see survivors.
sort.SliceStable(s.order, func(a, b int) bool {
return s.Entries[s.order[a]].F.Needs() < s.Entries[s.order[b]].F.Needs()
})
}
// Needs reports the union of the enabled filters' requirements.
func (s *Stack) Needs() Need { return s.needs }
// Match evaluates the enabled filters in cost order.
func (s *Stack) Match(c *Ctx) bool {
for _, i := range s.order {
e := s.Entries[i]
if e.F.Match(c) == e.Negate {
return false
}
}
return true
}
// Add appends an enabled filter and recompiles.
func (s *Stack) Add(f Filter) {
s.Entries = append(s.Entries, Entry{F: f, Enabled: true})
s.Compile()
}
// Find returns the first entry of the given kind.
func (s *Stack) Find(kind string) (*Entry, bool) {
for i := range s.Entries {
if s.Entries[i].F.Kind() == kind {
return &s.Entries[i], true
}
}
return nil, false
}
// Set replaces the entry of f's kind, appending when absent.
func (s *Stack) Set(f Filter) {
for i := range s.Entries {
if s.Entries[i].F.Kind() == f.Kind() {
s.Entries[i].F, s.Entries[i].Enabled = f, true
s.Compile()
return
}
}
s.Add(f)
}
// Remove drops the entry of the given kind.
func (s *Stack) Remove(kind string) bool {
for i := range s.Entries {
if s.Entries[i].F.Kind() == kind {
s.Entries = append(s.Entries[:i], s.Entries[i+1:]...)
s.Compile()
return true
}
}
return false
}
// Summary renders the stack for the status bar: (disabled) and !negated.
func (s *Stack) Summary() []string {
out := make([]string, 0, len(s.Entries))
for _, e := range s.Entries {
l := e.F.Label()
if l == "" {
continue // filter is inert; the header strip or nothing reports it
}
if e.Negate {
l = "!" + l
}
if !e.Enabled {
l = "(" + l + ")"
}
out = append(out, l)
}
return out
}
// Desc describes a registered filter kind for menus and help.
type Desc struct {
Kind string
Help string
New func(arg string) (Filter, error)
}
var registry []Desc
// Register makes a filter kind constructible by name. A new kind is a new file
// plus one Register call; no render-path switch changes.
func Register(d Desc) { registry = append(registry, d) }
// Kinds returns the registered filter kinds.
func Kinds() []Desc { return registry }
// New constructs a registered filter.
func New(kind, arg string) (Filter, error) {
for _, d := range registry {
if d.Kind == kind {
return d.New(arg)
}
}
return nil, fmt.Errorf("filter: unknown kind %q", kind)
}
+83
View File
@@ -0,0 +1,83 @@
package filter
import (
"strconv"
"github.com/lixenwraith/vif-log/internal/logfile"
)
func init() {
Register(Desc{Kind: "find", Help: "regexp over the focused column, smart-case", New: newFind})
}
// Find keeps records whose focused column matches the pattern. Time, tick, sub
// and msg resolve from the index row, so only fields and all read the file.
type Find struct {
Query string
Col logfile.Column
pat Pattern
scratch []byte
}
// NewFind returns an inert find filter.
func NewFind() *Find { return &Find{} }
func newFind(arg string) (Filter, error) {
f := NewFind()
if err := f.Set(arg, logfile.ColAll); err != nil {
return nil, err
}
return f, nil
}
// Set replaces the pattern and its column scope.
func (f *Find) Set(q string, col logfile.Column) error {
p, err := NewPattern(q)
if err != nil {
return err
}
f.Query, f.Col, f.pat = q, col, p
return nil
}
// Active reports whether the filter constrains anything.
func (f *Find) Active() bool { return !f.pat.Empty() }
func (f *Find) Kind() string { return "find" }
func (f *Find) Needs() Need {
if !f.Active() {
return 0
}
switch f.Col {
case logfile.ColFields, logfile.ColAll:
return NeedFields
}
return 0
}
func (f *Find) Match(c *Ctx) bool {
if !f.Active() {
return true
}
switch f.Col {
case logfile.ColTime:
f.scratch = append(f.scratch[:0], logfile.StampText(c.Meta)...)
case logfile.ColTick:
f.scratch = strconv.AppendUint(f.scratch[:0], uint64(c.Meta.Tick), 10)
case logfile.ColSub:
f.scratch = append(f.scratch[:0], c.Idx.SubName(c.Meta.Sub)...)
case logfile.ColMsg:
f.scratch = append(f.scratch[:0], c.Idx.MsgName(c.Meta.Msg)...)
default:
return f.pat.MatchBytes(c.Record().ColumnBytes(f.Col))
}
return f.pat.MatchBytes(f.scratch)
}
func (f *Find) Label() string {
if !f.Active() {
return ""
}
return "/" + f.Col.String() + ":" + f.Query
}
+166
View File
@@ -0,0 +1,166 @@
package filter
import (
"fmt"
"strings"
"github.com/lixenwraith/vif-log/internal/logfile"
)
func init() {
Register(Desc{Kind: "level", Help: "level set (IWE) or threshold (>=WARN)", New: newLevel})
}
// Level filters by a bitmask over logfile.Level. Threshold mode is a mutation
// of the mask, not a second representation.
type Level struct{ Mask uint16 }
// NewLevel returns a level filter with every level enabled.
func NewLevel() *Level { return &Level{Mask: (1 << uint(logfile.LevelCount)) - 1} }
func newLevel(arg string) (Filter, error) {
f := NewLevel()
arg = strings.TrimSpace(arg)
if arg == "" {
return f, nil
}
if t, ok := strings.CutPrefix(arg, ">="); ok {
l, found := levelByName(strings.TrimSpace(t))
if !found {
return nil, fmt.Errorf("filter/level: unknown level %q", t)
}
f.Threshold(l)
return f, nil
}
f.Mask = 0
for i := 0; i < len(arg); i++ {
l, ok := logfile.LevelByInitial(arg[i] &^ 0x20)
if !ok {
return nil, fmt.Errorf("filter/level: unknown level initial %q", arg[i])
}
f.Mask |= 1 << uint(l)
}
return f, nil
}
func levelByName(s string) (logfile.Level, bool) {
s = strings.ToUpper(s)
for i := logfile.Level(0); i < logfile.LevelCount; i++ {
if i.String() == s {
return i, true
}
}
return logfile.LevelBad, false
}
func (f *Level) Kind() string { return "level" }
func (f *Level) Needs() Need { return 0 }
func (f *Level) Match(c *Ctx) bool { return f.Mask&(1<<uint(c.Meta.Lvl)) != 0 }
// Has reports whether level l passes.
func (f *Level) Has(l logfile.Level) bool { return f.Mask&(1<<uint(l)) != 0 }
// Toggle flips one level.
func (f *Level) Toggle(l logfile.Level) { f.Mask ^= 1 << uint(l) }
// SetAll enables or disables every level.
func (f *Level) SetAll(on bool) {
if on {
f.Mask = (1 << uint(logfile.LevelCount)) - 1
} else {
f.Mask = 0
}
}
// Threshold enables the ordered levels at or above l, leaving PROC and BAD alone.
func (f *Level) Threshold(l logfile.Level) {
for i := range logfile.Level(logfile.OrderedCount) {
if i >= l {
f.Mask |= 1 << uint(i)
} else {
f.Mask &^= 1 << uint(i)
}
}
}
// ThresholdToggle applies the threshold at l, or restores every ordered level
// when the mask already has exactly that shape. Digit keys use this: 2 hides
// TRACE, 2 again brings it back.
func (f *Level) ThresholdToggle(l logfile.Level) {
if f.isThreshold(l) {
for i := range logfile.Level(logfile.OrderedCount) {
f.Mask |= 1 << uint(i)
}
return
}
f.Threshold(l)
}
// isThreshold reports whether the ordered levels are exactly those at or above l.
func (f *Level) isThreshold(l logfile.Level) bool {
for i := range logfile.Level(logfile.OrderedCount) {
if f.Has(i) != (i >= l) {
return false
}
}
return true
}
// AllOn reports whether no level is filtered out.
func (f *Level) AllOn() bool {
return f.Mask&((1<<uint(logfile.LevelCount))-1) == (1<<uint(logfile.LevelCount))-1
}
// Shift moves the threshold by d, clamped to the ordered range.
func (f *Level) Shift(d int) {
t := int(f.LowestOrdered())
t += d
if t < 0 {
t = 0
}
if t >= logfile.OrderedCount {
t = logfile.OrderedCount - 1
}
f.Threshold(logfile.Level(t))
}
// LowestOrdered returns the lowest enabled ordered level, ERROR if none.
func (f *Level) LowestOrdered() logfile.Level {
for i := logfile.Level(0); i < logfile.Level(logfile.OrderedCount); i++ {
if f.Has(i) {
return i
}
}
return logfile.LevelError
}
// Label reports the level filter only when it hides something; the header
// strip shows the per-level state.
func (f *Level) Label() string {
if f.AllOn() {
return ""
}
if l, ok := f.thresholdShape(); ok {
return "lvl>=" + l.String()
}
var b strings.Builder
b.WriteString("lvl:")
for i := range logfile.LevelCount {
if f.Has(i) {
b.WriteByte(i.Initial())
}
}
return b.String()
}
// thresholdShape reports whether the mask is "ordered >= t, plus PROC and BAD".
func (f *Level) thresholdShape() (logfile.Level, bool) {
if !f.Has(logfile.LevelProc) || !f.Has(logfile.LevelBad) {
return 0, false
}
t := f.LowestOrdered()
if t == 0 || !f.isThreshold(t) {
return 0, false
}
return t, true
}
+110
View File
@@ -0,0 +1,110 @@
package filter
import (
"bytes"
"regexp"
"strings"
)
// Pattern is a smart-case matcher: an all-lowercase pattern matches
// case-insensitively, any upper-case character makes it case-sensitive.
// A pattern free of regexp metacharacters takes an allocation-free
// substring path; everything else compiles to stdlib RE2.
type Pattern struct {
Src string
re *regexp.Regexp
lit []byte
fold bool
}
// NewPattern compiles s; an empty s yields an inert pattern.
func NewPattern(s string) (Pattern, error) {
p := Pattern{Src: s, fold: s == strings.ToLower(s)}
if s == "" {
return p, nil
}
if regexp.QuoteMeta(s) == s {
if p.fold {
p.lit = []byte(strings.ToLower(s))
} else {
p.lit = []byte(s)
}
return p, nil
}
expr := s
if p.fold {
expr = "(?i)" + s
}
re, err := regexp.Compile(expr)
if err != nil {
return Pattern{}, err
}
p.re = re
return p, nil
}
// Empty reports whether the pattern constrains nothing.
func (p Pattern) Empty() bool { return p.Src == "" }
// MatchBytes reports whether b contains a match.
func (p Pattern) MatchBytes(b []byte) bool {
switch {
case p.Src == "":
return true
case p.lit != nil && p.fold:
return foldContains(b, p.lit)
case p.lit != nil:
return bytes.Contains(b, p.lit)
default:
return p.re.Match(b)
}
}
// MatchString reports whether s contains a match.
func (p Pattern) MatchString(s string) bool {
switch {
case p.Src == "":
return true
case p.lit != nil && p.fold:
return foldContains([]byte(s), p.lit)
case p.lit != nil:
return strings.Contains(s, string(p.lit))
default:
return p.re.MatchString(s)
}
}
func lowerASCII(c byte) byte {
if c >= 'A' && c <= 'Z' {
return c + 'a' - 'A'
}
return c
}
// foldContains reports whether hay contains needle, ASCII case-insensitively.
// needle must already be lowercased.
func foldContains(hay, needle []byte) bool {
if len(needle) == 0 {
return true
}
if len(hay) < len(needle) {
return false
}
first := needle[0]
for i := 0; i+len(needle) <= len(hay); i++ {
if lowerASCII(hay[i]) != first {
continue
}
match := true
for j := 1; j < len(needle); j++ {
if lowerASCII(hay[i+j]) != needle[j] {
match = false
break
}
}
if match {
return true
}
}
return false
}
+64
View File
@@ -0,0 +1,64 @@
package filter
import "slices"
// PinSet is the pin buffer: record indices the user marked. It is owned by the
// app and outlives every filter change, which is the point — pins are
// assembled across successive filters. Not registered: it has no meaningful
// construction from a string argument.
type PinSet struct{ m map[int32]struct{} }
// NewPinSet returns an empty pin buffer.
func NewPinSet() *PinSet { return &PinSet{m: make(map[int32]struct{})} }
// Has reports whether record i is pinned.
func (p *PinSet) Has(i int32) bool { _, ok := p.m[i]; return ok }
// Toggle flips record i, returning its new state.
func (p *PinSet) Toggle(i int32) bool {
if _, ok := p.m[i]; ok {
delete(p.m, i)
return false
}
p.m[i] = struct{}{}
return true
}
// Clear empties the buffer.
func (p *PinSet) Clear() { clear(p.m) }
// Len returns the pin count.
func (p *PinSet) Len() int { return len(p.m) }
// Sorted returns the pinned record indices in file order.
func (p *PinSet) Sorted() []int32 {
out := make([]int32, 0, len(p.m))
for i := range p.m {
out = append(out, i)
}
slices.Sort(out)
return out
}
// Pinned restricts the view to the pin buffer.
type Pinned struct {
Set *PinSet
On bool
}
// NewPinned wraps a pin buffer as a filter.
func NewPinned(s *PinSet) *Pinned { return &Pinned{Set: s} }
func (f *Pinned) Kind() string { return "pin" }
func (f *Pinned) Needs() Need { return 0 }
func (f *Pinned) Match(c *Ctx) bool {
return !f.On || f.Set.Has(int32(c.I))
}
func (f *Pinned) Label() string {
if !f.On {
return ""
}
return "pinned-only"
}
+64
View File
@@ -0,0 +1,64 @@
package filter
import (
"strings"
"github.com/lixenwraith/vif-log/internal/logfile"
)
func init() {
Register(Desc{Kind: "snap", Help: "collapse stat snapshots to one line (arg: off)", New: newCollapse})
}
// Collapse hides stat-snapshot members, turning a ~40-record snapshot into one
// navigable line. On is the global state; exc holds the per-group exceptions
// toggled with Enter. Index-only, so it costs nothing to evaluate.
type Collapse struct {
On bool
exc map[uint32]bool
}
// NewCollapse returns a collapse filter in the given global state.
func NewCollapse(on bool) *Collapse {
return &Collapse{On: on, exc: make(map[uint32]bool)}
}
func newCollapse(arg string) (Filter, error) {
return NewCollapse(strings.TrimSpace(arg) != "off"), nil
}
func (f *Collapse) Kind() string { return "snap" }
func (f *Collapse) Needs() Need { return 0 }
func (f *Collapse) Match(c *Ctx) bool {
if c.Meta.Snap == 0 || c.Meta.Flags&logfile.FlagSnapHead != 0 {
return true
}
return f.Expanded(c.Meta.Snap)
}
// Expanded reports whether group id shows its members.
func (f *Collapse) Expanded(id uint32) bool { return f.On == f.exc[id] }
// ToggleGroup flips one group against the global state.
func (f *Collapse) ToggleGroup(id uint32) { f.exc[id] = !f.exc[id] }
// ResetGroups drops the per-group exceptions; group ids are file-scoped.
func (f *Collapse) ResetGroups() { clear(f.exc) }
// Toggle flips the global state and drops all per-group exceptions.
func (f *Collapse) Toggle() {
f.On = !f.On
clear(f.exc)
}
func (f *Collapse) Label() string {
s := "snap:expanded"
if f.On {
s = "snap:collapsed"
}
if len(f.exc) > 0 {
s += "*"
}
return s
}
+80
View File
@@ -0,0 +1,80 @@
package filter
import (
"fmt"
"strconv"
"strings"
)
func init() {
Register(Desc{Kind: "tick", Help: "tick range: N | N-M | N- | -M", New: newTickSpan})
Register(Desc{Kind: "run", Help: "run range: N | N-M | N- | -M", New: newRunSpan})
}
// Span is an inclusive numeric range over an index-resident counter.
type Span struct {
field string
Lo, Hi uint32
Query string
}
func newTickSpan(arg string) (Filter, error) { return newSpan("tick", arg) }
func newRunSpan(arg string) (Filter, error) { return newSpan("run", arg) }
func newSpan(field, arg string) (Filter, error) {
lo, hi, err := parseSpan(arg)
if err != nil {
return nil, fmt.Errorf("filter/%s: %w", field, err)
}
return &Span{field: field, Lo: lo, Hi: hi, Query: arg}, nil
}
// parseSpan accepts N, N-M, N- and -M; an empty spec is the full range.
func parseSpan(arg string) (uint32, uint32, error) {
arg = strings.TrimSpace(arg)
if arg == "" {
return 0, ^uint32(0), nil
}
lo, hi := arg, arg
if i := strings.IndexByte(arg, '-'); i >= 0 {
lo, hi = arg[:i], arg[i+1:]
}
var l uint32
h := ^uint32(0)
if lo != "" {
v, err := strconv.ParseUint(lo, 10, 32)
if err != nil {
return 0, 0, err
}
l = uint32(v)
}
if hi != "" {
v, err := strconv.ParseUint(hi, 10, 32)
if err != nil {
return 0, 0, err
}
h = uint32(v)
}
if l > h {
l, h = h, l
}
return l, h, nil
}
func (f *Span) Kind() string { return f.field }
func (f *Span) Needs() Need { return 0 }
func (f *Span) Match(c *Ctx) bool {
v := c.Meta.Tick
if f.field == "run" {
v = c.Meta.Run
}
return v >= f.Lo && v <= f.Hi
}
func (f *Span) Label() string {
if f.Lo == 0 && f.Hi == ^uint32(0) {
return ""
}
return f.field + ":" + f.Query
}
+86
View File
@@ -0,0 +1,86 @@
package filter
import "github.com/lixenwraith/vif-log/internal/logfile"
func init() {
Register(Desc{Kind: "sub", Help: "subsystem regexp, e.g. sub:^(fsm|rec)$", New: newSub})
Register(Desc{Kind: "msg", Help: "message regexp, e.g. msg:transition", New: newMsg})
}
// Text matches one of the interned vocabulary columns. The pattern is applied
// once per interned id into a bitset, so the per-record cost is a bit probe
// and no line is ever read.
type Text struct {
kind string
Query string
pat Pattern
mask []uint64
n int
}
func newSub(arg string) (Filter, error) { return newText("sub", arg) }
func newMsg(arg string) (Filter, error) { return newText("msg", arg) }
func newText(kind, arg string) (Filter, error) {
p, err := NewPattern(arg)
if err != nil {
return nil, err
}
return &Text{kind: kind, Query: arg, pat: p}, nil
}
func (f *Text) Kind() string { return f.kind }
func (f *Text) Needs() Need { return 0 }
func (f *Text) Match(c *Ctx) bool {
if f.pat.Empty() {
return true
}
var id int
var tab []string
if f.kind == "sub" {
id, tab = int(c.Meta.Sub), c.Idx.Subs()
} else {
id, tab = int(c.Meta.Msg), c.Idx.Msgs()
}
f.ensure(tab)
if id < 0 || id >= f.n {
return false
}
return f.mask[id>>6]&(1<<uint(id&63)) != 0
}
// ensure rebuilds the bitset when the interned table has grown.
func (f *Text) ensure(tab []string) {
if f.mask != nil && f.n == len(tab) {
return
}
need := (len(tab) + 63) / 64
if cap(f.mask) < need {
f.mask = make([]uint64, need)
} else {
f.mask = f.mask[:need]
clear(f.mask)
}
for i, s := range tab {
if f.pat.MatchString(s) {
f.mask[i>>6] |= 1 << uint(i&63)
}
}
f.n = len(tab)
}
func (f *Text) Label() string {
if f.pat.Empty() {
return ""
}
return f.kind + ":" + f.Query
}
// SubOf resolves the column a text filter binds to; used by the help overlay.
func (f *Text) Column() logfile.Column {
if f.kind == "sub" {
return logfile.ColSub
}
return logfile.ColMsg
}
+351
View File
@@ -0,0 +1,351 @@
package keys
import (
"slices"
"strings"
"github.com/lixenwraith/terminal"
)
// Action is a UI verb. The table maps chords to actions; the app maps actions
// to functions. Adding a key is one table row.
type Action uint16
const (
ActNone Action = iota
ActQuit
ActRedraw
ActHelp
ActCloseOverlay
ActDown
ActUp
ActPageDown
ActPageUp
ActHalfDown
ActHalfUp
ActTop
ActBottom
ActColNext
ActColPrev
ActSort
ActDetailDown
ActDetailUp
ActSearch
ActFollowNext
ActFollowPrev
ActFilter // add/replace a stack filter
ActClear
ActExpand
ActSnapNext
ActSnapPrev
ActPinToggle
ActPinOnly
ActPinClear
ActOpen
ActExport
ActMark // overlay: toggle multi-selection
ActConfirm // overlay: activate the selected row
ActBack // overlay: leave the current level
ActLvlTrace
ActLvlDebug
ActLvlInfo
ActLvlWarn
ActLvlError
ActLvlProc
ActLvlBad
ActLvlAll
ActLvlRaise
ActLvlLower
ActThresh1
ActThresh2
ActThresh3
ActThresh4
ActThresh5
)
// Mode scopes a binding to an input context.
type Mode uint8
const (
ModeNormal Mode = iota
ModeOverlay
)
// Chord is one key press.
type Chord struct {
Key terminal.Key
Rune rune
Mod terminal.Modifier
}
// R builds a printable-rune chord.
func R(r rune) Chord { return Chord{Key: terminal.KeyRune, Rune: r} }
// K builds a named-key chord.
func K(k terminal.Key) Chord { return Chord{Key: k} }
// Binding is one row of the single key table that also generates the help overlay.
type Binding struct {
Mode Mode
Seq []Chord
Act Action
Group string
Help string
}
// Bindings is the sole source of truth for keys, help text and footer hints.
// An empty Help hides the row from the overlay without unbinding the key: the
// level letters and the threshold digits are documented as one row each.
var Bindings = []Binding{
{ModeNormal, []Chord{R('q')}, ActQuit, "general", "quit"},
{ModeNormal, []Chord{K(terminal.KeyCtrlC)}, ActQuit, "general", "quit"},
{ModeNormal, []Chord{K(terminal.KeyCtrlL)}, ActRedraw, "general", "redraw"},
{ModeNormal, []Chord{R('?')}, ActHelp, "general", "help"},
{ModeNormal, []Chord{R('j')}, ActDown, "move", "down"},
{ModeNormal, []Chord{K(terminal.KeyDown)}, ActDown, "move", "down"},
{ModeNormal, []Chord{R('k')}, ActUp, "move", "up"},
{ModeNormal, []Chord{K(terminal.KeyUp)}, ActUp, "move", "up"},
{ModeNormal, []Chord{K(terminal.KeyPageDown)}, ActPageDown, "move", "page down"},
{ModeNormal, []Chord{K(terminal.KeyPageUp)}, ActPageUp, "move", "page up"},
{ModeNormal, []Chord{K(terminal.KeyCtrlD)}, ActHalfDown, "move", "half page down"},
{ModeNormal, []Chord{K(terminal.KeyCtrlU)}, ActHalfUp, "move", "half page up"},
{ModeNormal, []Chord{R('g'), R('g')}, ActTop, "move", "first record"},
{ModeNormal, []Chord{K(terminal.KeyHome)}, ActTop, "move", "first record"},
{ModeNormal, []Chord{R('G')}, ActBottom, "move", "last record"},
{ModeNormal, []Chord{K(terminal.KeyEnd)}, ActBottom, "move", "last record"},
{ModeNormal, []Chord{K(terminal.KeyTab)}, ActColNext, "column", "focus next column"},
{ModeNormal, []Chord{K(terminal.KeyBacktab)}, ActColPrev, "column", "focus previous column"},
{ModeNormal, []Chord{R('s')}, ActSort, "column", "sort focused column: asc / desc / off"},
{ModeNormal, []Chord{R('J')}, ActDetailDown, "column", "scroll detail down"},
{ModeNormal, []Chord{R('K')}, ActDetailUp, "column", "scroll detail up"},
{ModeNormal, []Chord{R('/')}, ActSearch, "search", "search the focused column"},
{ModeNormal, []Chord{K(terminal.KeyEscape)}, ActClear, "search", "clear search and filters"},
{ModeNormal, []Chord{R('f')}, ActFollowNext, "search", "next record like this one"},
{ModeNormal, []Chord{R('F')}, ActFollowPrev, "search", "previous record like this one"},
{ModeNormal, []Chord{R('\\')}, ActFilter, "search", "filter: kind:regexp (sub msg tick run level fields)"},
{ModeNormal, []Chord{K(terminal.KeyEnter)}, ActExpand, "snapshot", "expand/collapse this snapshot"},
{ModeNormal, []Chord{R('n')}, ActSnapNext, "snapshot", "jump to next snapshot down"},
{ModeNormal, []Chord{R('N')}, ActSnapPrev, "snapshot", "jump to previous snapshot up"},
{ModeNormal, []Chord{R(' ')}, ActPinToggle, "pin", "pin/unpin record"},
{ModeNormal, []Chord{R('P')}, ActPinOnly, "pin", "show only pinned records"},
{ModeNormal, []Chord{R('C')}, ActPinClear, "pin", "clear all pins"},
{ModeNormal, []Chord{R('o')}, ActOpen, "file", "open a log file"},
{ModeNormal, []Chord{R('x')}, ActExport, "file", "export pins, or the current result"},
{ModeNormal, []Chord{R('t')}, ActLvlTrace, "level", "toggle one level: t d i w e p b"},
{ModeNormal, []Chord{R('d')}, ActLvlDebug, "level", ""},
{ModeNormal, []Chord{R('i')}, ActLvlInfo, "level", ""},
{ModeNormal, []Chord{R('w')}, ActLvlWarn, "level", ""},
{ModeNormal, []Chord{R('e')}, ActLvlError, "level", ""},
{ModeNormal, []Chord{R('p')}, ActLvlProc, "level", ""},
{ModeNormal, []Chord{R('b')}, ActLvlBad, "level", ""},
{ModeNormal, []Chord{R('1')}, ActThresh1, "level", "hide below level: 1=TRACE … 5=ERROR"},
{ModeNormal, []Chord{R('2')}, ActThresh2, "level", ""},
{ModeNormal, []Chord{R('3')}, ActThresh3, "level", ""},
{ModeNormal, []Chord{R('4')}, ActThresh4, "level", ""},
{ModeNormal, []Chord{R('5')}, ActThresh5, "level", ""},
{ModeNormal, []Chord{R('6')}, ActLvlProc, "level", ""},
{ModeNormal, []Chord{R('7')}, ActLvlBad, "level", ""},
{ModeNormal, []Chord{R('0')}, ActLvlAll, "level", "show all levels"},
{ModeNormal, []Chord{R('>')}, ActLvlRaise, "level", "raise threshold"},
{ModeNormal, []Chord{R('<')}, ActLvlLower, "level", "lower threshold"},
{ModeOverlay, []Chord{K(terminal.KeyEscape)}, ActCloseOverlay, "overlay", "close"},
{ModeOverlay, []Chord{R('q')}, ActCloseOverlay, "overlay", "close"},
{ModeOverlay, []Chord{R('?')}, ActCloseOverlay, "overlay", ""},
{ModeOverlay, []Chord{R('j')}, ActDown, "overlay", "down"},
{ModeOverlay, []Chord{K(terminal.KeyDown)}, ActDown, "overlay", "down"},
{ModeOverlay, []Chord{R('k')}, ActUp, "overlay", "up"},
{ModeOverlay, []Chord{K(terminal.KeyUp)}, ActUp, "overlay", "up"},
{ModeOverlay, []Chord{K(terminal.KeyPageDown)}, ActPageDown, "overlay", "page down"},
{ModeOverlay, []Chord{K(terminal.KeyPageUp)}, ActPageUp, "overlay", "page up"},
{ModeOverlay, []Chord{K(terminal.KeyCtrlD)}, ActHalfDown, "overlay", ""},
{ModeOverlay, []Chord{K(terminal.KeyCtrlU)}, ActHalfUp, "overlay", ""},
{ModeOverlay, []Chord{R('g'), R('g')}, ActTop, "overlay", "first row"},
{ModeOverlay, []Chord{K(terminal.KeyHome)}, ActTop, "overlay", ""},
{ModeOverlay, []Chord{R('G')}, ActBottom, "overlay", "last row"},
{ModeOverlay, []Chord{K(terminal.KeyEnd)}, ActBottom, "overlay", ""},
{ModeOverlay, []Chord{K(terminal.KeyEnter)}, ActConfirm, "overlay", "open file / enter directory"},
{ModeOverlay, []Chord{R('l')}, ActConfirm, "overlay", ""},
{ModeOverlay, []Chord{K(terminal.KeyRight)}, ActConfirm, "overlay", ""},
{ModeOverlay, []Chord{R('h')}, ActBack, "overlay", "parent directory"},
{ModeOverlay, []Chord{K(terminal.KeyLeft)}, ActBack, "overlay", ""},
{ModeOverlay, []Chord{K(terminal.KeyBackspace)}, ActBack, "overlay", ""},
{ModeOverlay, []Chord{R(' ')}, ActMark, "overlay", "mark file for multi-open"},
}
type skey struct {
m Mode
c Chord
}
type qkey struct {
m Mode
a, b Chord
}
// Resolver turns chords into actions, holding one pending prefix for two-chord
// sequences.
type Resolver struct {
single map[skey]Action
prefix map[skey]bool
seq map[qkey]Action
pending Chord
armed bool
}
// NewResolver compiles the binding table.
func NewResolver() *Resolver {
r := &Resolver{
single: make(map[skey]Action, len(Bindings)),
prefix: make(map[skey]bool),
seq: make(map[qkey]Action),
}
for _, b := range Bindings {
switch len(b.Seq) {
case 1:
r.single[skey{b.Mode, b.Seq[0]}] = b.Act
case 2:
r.prefix[skey{b.Mode, b.Seq[0]}] = true
r.seq[qkey{b.Mode, b.Seq[0], b.Seq[1]}] = b.Act
}
}
return r
}
// Resolve maps a chord to an action, returning ActNone while a prefix pends.
func (r *Resolver) Resolve(m Mode, c Chord) Action {
if r.armed {
r.armed = false
if a, ok := r.seq[qkey{m, r.pending, c}]; ok {
return a
}
}
if r.prefix[skey{m, c}] {
r.pending, r.armed = c, true
return ActNone
}
return r.single[skey{m, c}]
}
// Reset drops any pending prefix.
func (r *Resolver) Reset() { r.armed = false }
// FromEvent builds a chord from a key event. Shift is dropped where the key
// identity already encodes it: runes carry their shifted form, and backtab is
// shift+tab by definition.
func FromEvent(ev terminal.Event) Chord {
c := Chord{Key: ev.Key, Mod: ev.Modifiers}
switch ev.Key {
case terminal.KeyRune:
c.Rune = ev.Rune
c.Mod &^= terminal.ModShift
case terminal.KeyBacktab:
c.Mod &^= terminal.ModShift
}
return c
}
// ChordString renders a chord for help and hints.
func ChordString(c Chord) string {
var b strings.Builder
if c.Mod&terminal.ModCtrl != 0 {
b.WriteByte('^')
}
if c.Mod&terminal.ModAlt != 0 {
b.WriteString("M-")
}
if c.Key == terminal.KeyRune {
if c.Rune == ' ' {
b.WriteString("spc")
} else {
b.WriteRune(c.Rune)
}
return b.String()
}
if n := terminal.KeyName(c.Key); n != "" {
b.WriteString(n)
return b.String()
}
b.WriteByte('?')
return b.String()
}
// SeqString renders a chord sequence.
func SeqString(s []Chord) string {
var b strings.Builder
for _, c := range s {
b.WriteString(ChordString(c))
}
return b.String()
}
// KeysFor returns the chords bound to act, joined, for footer hints.
func KeysFor(m Mode, act Action) string {
var out []string
for _, b := range Bindings {
if b.Mode == m && b.Act == act {
out = append(out, SeqString(b.Seq))
}
}
return strings.Join(out, "/")
}
// HelpRow is one generated line of the help overlay.
type HelpRow struct {
Group string
Keys string
Help string
}
// HelpRows generates the help overlay content from the binding table, one row
// per action with its chords merged.
func HelpRows(m Mode) []HelpRow {
type acc struct {
keys []string
help, group string
}
byAct := map[Action]*acc{}
var order []Action
var groups []string
for _, b := range Bindings {
if b.Mode != m || b.Help == "" {
continue
}
a, ok := byAct[b.Act]
if !ok {
a = &acc{help: b.Help, group: b.Group}
byAct[b.Act] = a
order = append(order, b.Act)
if !slices.Contains(groups, b.Group) {
groups = append(groups, b.Group)
}
}
a.keys = append(a.keys, SeqString(b.Seq))
}
out := make([]HelpRow, 0, len(order))
for _, g := range groups {
for _, act := range order {
if a := byAct[act]; a.group == g {
out = append(out, HelpRow{Group: g, Keys: strings.Join(a.keys, " "), Help: a.help})
}
}
}
return out
}
+346
View File
@@ -0,0 +1,346 @@
package logfile
import (
"io"
"os"
"sync"
"sync/atomic"
)
// Meta is one index row: 48 bytes, pointer-free, holds no line bytes.
type Meta struct {
Off int64
TS int64
Len uint32
Run uint32
Tick uint32
Frame uint32
Msg uint32 // interned fields.msg
Snap uint32 // stat-snapshot group id, 0 = none
Sub uint16 // interned sub
Src uint16 // source file index
Lvl Level
Flags uint8
}
// Meta flag bits.
const (
FlagMalformed uint8 = 1 << iota
FlagSnapHead
FlagTrace
FlagNoTime // TS was inherited from the previous line, for ordering only
)
// Snapshot groups the stat records sharing one (src, run, tick). Members are
// not contiguous in the merged view; Count is authoritative, Head locates the
// group's first row in the published order.
type Snapshot struct {
Head uint32
Count uint32
Run uint32
Tick uint32
Frame uint32
Src uint16
}
// Source is one indexed file.
type Source struct {
Path string
Name string
Size int64
scanned atomic.Int64
bad atomic.Int64
done atomic.Bool
failure atomic.Pointer[scanErr]
}
// Index is the append-only line index over one or more files. The scanner
// publishes immutable slice headers; readers load them atomically, so the
// render path never locks. A single source publishes incrementally; several
// sources publish once, merged by timestamp, so row indices never shift.
type Index struct {
srcs []*Source
subN, msgN *interner
metas atomic.Pointer[[]Meta]
snaps atomic.Pointer[[]Snapshot]
subs atomic.Pointer[[]string]
msgs atomic.Pointer[[]string]
}
type scanErr struct{ err error }
// The accessors below tolerate a nil receiver: the viewer runs with no file
// open until one is chosen, and the render path reads the index every frame.
// Sources returns the indexed files in source-id order.
func (x *Index) Sources() []*Source {
if x == nil {
return nil
}
return x.srcs
}
// SrcCount returns the number of indexed files.
func (x *Index) SrcCount() int {
if x == nil {
return 0
}
return len(x.srcs)
}
// SrcName resolves a source id to its base filename.
func (x *Index) SrcName(id uint16) string {
if x == nil || int(id) >= len(x.srcs) {
return ""
}
return x.srcs[id].Name
}
// SrcMark returns the one-character gutter label for a source id.
func (x *Index) SrcMark(id uint16) rune {
const marks = "123456789abcdefghijklmnopqrstuvwxyz"
if int(id) >= len(marks) {
return '?'
}
return rune(marks[id])
}
// Metas returns the currently published index rows.
func (x *Index) Metas() []Meta {
if x == nil {
return nil
}
if p := x.metas.Load(); p != nil {
return *p
}
return nil
}
// Len returns the number of indexed records.
func (x *Index) Len() int { return len(x.Metas()) }
// Snaps returns the published snapshot groups.
func (x *Index) Snaps() []Snapshot {
if x == nil {
return nil
}
if p := x.snaps.Load(); p != nil {
return *p
}
return nil
}
// Subs returns the interned subsystem table.
func (x *Index) Subs() []string {
if x == nil {
return nil
}
return strTable(x.subs.Load())
}
// Msgs returns the interned fields.msg table.
func (x *Index) Msgs() []string {
if x == nil {
return nil
}
return strTable(x.msgs.Load())
}
// SubName resolves an interned sub id.
func (x *Index) SubName(id uint16) string {
if x == nil {
return ""
}
return lookup(x.subs.Load(), int(id))
}
// MsgName resolves an interned fields.msg id.
func (x *Index) MsgName(id uint32) string {
if x == nil {
return ""
}
return lookup(x.msgs.Load(), int(id))
}
func strTable(p *[]string) []string {
if p == nil {
return nil
}
return *p
}
func lookup(p *[]string, i int) string {
t := strTable(p)
if i < 0 || i >= len(t) {
return ""
}
return t[i]
}
// Progress reports bytes scanned, total bytes and completion across all sources.
func (x *Index) Progress() (scanned, total int64, complete bool) {
if x == nil {
return 0, 0, true
}
complete = true
for _, s := range x.srcs {
scanned += s.scanned.Load()
total += s.Size
if !s.done.Load() {
complete = false
}
}
return scanned, total, complete
}
// Malformed returns the count of unparseable lines seen so far.
func (x *Index) Malformed() int64 {
if x == nil {
return 0
}
var n int64
for _, s := range x.srcs {
n += s.bad.Load()
}
return n
}
// Err returns the first scan failure, if any.
func (x *Index) Err() error {
if x == nil {
return nil
}
for _, s := range x.srcs {
if p := s.failure.Load(); p != nil {
return p.err
}
}
return nil
}
// SnapshotOf returns the stat group a record belongs to. The group currently
// being scanned is not published, so ok=false until it closes.
func (x *Index) SnapshotOf(m Meta) (Snapshot, bool) {
if x == nil || m.Snap == 0 {
return Snapshot{}, false
}
s := x.Snaps()
if int(m.Snap) > len(s) {
return Snapshot{}, false
}
return s[m.Snap-1], true
}
const readWindow = 256 << 10
// Reader fetches raw line bytes through a per-source sliding window.
// Not safe for concurrent use; create one per goroutine.
type Reader struct {
paths []string
win []window
}
type window struct {
f *os.File
buf []byte
base int64
n int
}
// NewReader opens an independent handle set on the indexed files. Handles are
// opened lazily, so a reader that only touches one source costs one fd.
func (x *Index) NewReader() (*Reader, error) {
r := &Reader{
paths: make([]string, len(x.srcs)),
win: make([]window, len(x.srcs)),
}
for i, s := range x.srcs {
r.paths[i] = s.Path
r.win[i].base = -1
}
return r, nil
}
// Line returns the raw bytes of m, valid until the next call.
func (r *Reader) Line(m Meta) ([]byte, error) {
n := int(m.Len)
if n == 0 || int(m.Src) >= len(r.win) {
return nil, nil
}
w := &r.win[m.Src]
if w.f == nil {
f, err := os.Open(r.paths[m.Src])
if err != nil {
return nil, err
}
w.f, w.buf, w.base = f, make([]byte, readWindow), -1
}
if n+4096 > len(w.buf) {
w.buf = make([]byte, n+4096)
w.base = -1
}
if w.base < 0 || m.Off < w.base || m.Off+int64(n) > w.base+int64(w.n) {
base := m.Off &^ 4095
got, err := w.f.ReadAt(w.buf, base)
if got == 0 && err != nil {
return nil, err
}
w.base, w.n = base, got
}
s := int(m.Off - w.base)
if s < 0 || s+n > w.n {
return nil, io.ErrUnexpectedEOF
}
return w.buf[s : s+n], nil
}
// Close releases every open file handle.
func (r *Reader) Close() error {
var err error
for i := range r.win {
if r.win[i].f != nil {
if e := r.win[i].f.Close(); e != nil && err == nil {
err = e
}
r.win[i].f = nil
}
}
return err
}
// interner maps byte tokens to dense ids. Writer-side only; the id→name table
// is published as an immutable snapshot. Locked: sources scan concurrently.
type interner struct {
mu sync.Mutex
ids map[string]uint32
tab []string
}
func newInterner() *interner {
n := &interner{ids: make(map[string]uint32, 64)}
n.intern(nil) // id 0 == ""
return n
}
func (n *interner) intern(b []byte) uint32 {
n.mu.Lock()
defer n.mu.Unlock()
if id, ok := n.ids[string(b)]; ok {
return id
}
s := string(b)
id := uint32(len(n.tab))
n.tab = append(n.tab, s)
n.ids[s] = id
return id
}
// table returns a consistent header over the interned names.
func (n *interner) table() []string {
n.mu.Lock()
defer n.mu.Unlock()
return n.tab[:len(n.tab):len(n.tab)]
}
+212
View File
@@ -0,0 +1,212 @@
package logfile
import (
"bytes"
"strconv"
)
// JSON token kinds.
const (
KNone byte = 0
KStr byte = 's'
KNum byte = 'n'
KBool byte = 'b'
KNull byte = 'z'
KObj byte = 'o'
KArr byte = 'a'
)
func skipSpace(b []byte, i int) int {
for i < len(b) && (b[i] == ' ' || b[i] == '\t' || b[i] == '\r' || b[i] == '\n') {
i++
}
return i
}
func isNumByte(c byte) bool {
return c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E' || (c >= '0' && c <= '9')
}
// scanString returns the index past the closing quote; i indexes the opener.
func scanString(b []byte, i int) (int, bool) {
for i++; i < len(b); i++ {
switch b[i] {
case '\\':
i++
case '"':
return i + 1, true
}
}
return i, false
}
// scanValue returns the end index and kind of the value starting at i.
func scanValue(b []byte, i int) (int, byte, bool) {
if i >= len(b) {
return i, KNone, false
}
switch c := b[i]; c {
case '"':
e, ok := scanString(b, i)
return e, KStr, ok
case '{', '[':
opener, closer, kind := byte('{'), byte('}'), KObj
if c == '[' {
opener, closer, kind = '[', ']', KArr
}
depth := 0
for i < len(b) {
ch := b[i]
if ch == '"' {
e, ok := scanString(b, i)
if !ok {
return e, kind, false
}
i = e
continue
}
if ch == opener {
depth++
} else if ch == closer {
depth--
if depth == 0 {
return i + 1, kind, true
}
}
i++
}
return i, kind, false
case 't', 'f', 'n':
kind := KBool
if c == 'n' {
kind = KNull
}
for i < len(b) && b[i] >= 'a' && b[i] <= 'z' {
i++
}
return i, kind, true
default:
for i < len(b) && isNumByte(b[i]) {
i++
}
return i, KNum, true
}
}
// eachField calls fn for each member of the object at i, which must index '{'.
// fn returning false stops iteration. Reports whether the object is well formed.
func eachField(b []byte, i int, fn func(key, val []byte, kind byte) bool) bool {
if i >= len(b) || b[i] != '{' {
return false
}
i = skipSpace(b, i+1)
if i < len(b) && b[i] == '}' {
return true
}
for i < len(b) {
if b[i] != '"' {
return false
}
ke, ok := scanString(b, i)
if !ok {
return false
}
key := b[i+1 : ke-1]
i = skipSpace(b, ke)
if i >= len(b) || b[i] != ':' {
return false
}
i = skipSpace(b, i+1)
vs := i
ve, kind, ok := scanValue(b, i)
if !ok {
return false
}
if !fn(key, b[vs:ve], kind) {
return true
}
i = skipSpace(b, ve)
if i >= len(b) {
return false
}
switch b[i] {
case ',':
i = skipSpace(b, i+1)
case '}':
return true
default:
return false
}
}
return false
}
// strTok returns the undecoded content of a string token.
func strTok(tok []byte) []byte {
if len(tok) < 2 {
return nil
}
return tok[1 : len(tok)-1]
}
// unquote returns the content of a string token, decoding escapes only when present.
func unquote(tok []byte) string {
in := strTok(tok)
if bytes.IndexByte(in, '\\') < 0 {
return string(in)
}
if s, err := strconv.Unquote(string(tok)); err == nil {
return s
}
return string(in)
}
// parseUint32 parses a leading decimal run, saturating at the type maximum.
func parseUint32(b []byte) uint32 {
var v uint64
for _, c := range b {
if c < '0' || c > '9' {
break
}
v = v*10 + uint64(c-'0')
if v > 0xffffffff {
return 0xffffffff
}
}
return uint32(v)
}
// parseInt64 parses a complete decimal integer token without allocating.
func parseInt64(b []byte) (int64, bool) {
if len(b) == 0 {
return 0, false
}
i, neg := 0, false
if b[0] == '-' || b[0] == '+' {
neg = b[0] == '-'
i++
}
if i >= len(b) {
return 0, false
}
var v int64
for ; i < len(b); i++ {
if b[i] < '0' || b[i] > '9' {
return 0, false
}
v = v*10 + int64(b[i]-'0')
if v < 0 {
return 0, false
}
}
if neg {
v = -v
}
return v, true
}
+241
View File
@@ -0,0 +1,241 @@
package logfile
import (
"bytes"
"strconv"
)
// Column identifies a list column for focus, search scope and sorting.
type Column uint8
const (
ColAll Column = iota
ColTime
ColTick
ColSub
ColMsg
ColFields
ColCount
)
var columnName = [ColCount]string{"all", "time", "tick", "sub", "msg", "fields"}
func (c Column) String() string {
if c < ColCount {
return columnName[c]
}
return "?"
}
// Next returns the column d steps away, wrapping.
func (c Column) Next(d int) Column {
return Column(((int(c)+d)%int(ColCount) + int(ColCount)) % int(ColCount))
}
// Field is one member of the fields object; Val is the raw JSON token.
type Field struct {
Key string
Val []byte
Kind byte
}
// Record is the parsed form of a line. It is the only type that knows the log
// schema: a new sub, a renamed key or a second format touches this file alone.
type Record struct {
Meta Meta
Raw []byte
Time string
Level string
Sub string
Trace string
Fields []Field
Bad bool
msgIdx int // index into Fields of the discriminator, -1 if none
buf []byte // column rendering scratch, reused across calls
}
// msgKeys are the discriminator keys in precedence order. Most records use
// fields.msg; the logger self-report uses fields.type.
var msgKeys = [...]string{"msg", "type"}
// Parse fills r from line. Slices alias line and are reused across calls, so
// copy anything retained past the next Parse.
func (r *Record) Parse(m Meta, line []byte) {
r.Meta, r.Raw = m, line
r.Time, r.Level, r.Sub, r.Trace = "", "", "", ""
r.Fields = r.Fields[:0]
r.msgIdx = -1
r.Bad = m.Flags&FlagMalformed != 0 || len(line) == 0
if r.Bad {
return
}
eachField(line, skipSpace(line, 0), func(k, v []byte, kind byte) bool {
switch string(k) {
case "time":
r.Time = string(strTok(v))
case "level":
r.Level = string(strTok(v))
case "sub":
r.Sub = string(strTok(v))
case "trace":
r.Trace = unquote(v)
case "fields":
if kind == KObj {
eachField(v, 0, func(fk, fv []byte, fkind byte) bool {
r.Fields = append(r.Fields, Field{Key: string(fk), Val: fv, Kind: fkind})
return true
})
}
}
return true
})
// Resolve the discriminator once: input records carry both msg and type,
// and only the one actually used may be dropped from the fields text.
for _, k := range msgKeys {
for i := range r.Fields {
if r.Fields[i].Key == k && r.Fields[i].Kind == KStr {
r.msgIdx = i
break
}
}
if r.msgIdx >= 0 {
break
}
}
}
// Get returns the named field.
func (r *Record) Get(key string) (Field, bool) {
for _, f := range r.Fields {
if f.Key == key {
return f, true
}
}
return Field{}, false
}
// Msg returns the record's discriminator.
func (r *Record) Msg() string {
if r.msgIdx < 0 {
return ""
}
return unquote(r.Fields[r.msgIdx].Val)
}
// FollowValue returns the first string field other than the discriminator: the
// value distinguishing records that share a (sub, msg) pair — ev for event
// dispatch, service for service records. Empty when every field is numeric.
func (r *Record) FollowValue() string {
for i, f := range r.Fields {
if i == r.msgIdx || f.Kind != KStr {
continue
}
return unquote(f.Val)
}
return ""
}
const fieldsTextCap = 512
// FieldsText is the fields column exactly as the record list renders it.
func (r *Record) FieldsText() string {
r.buf = r.appendFields(r.buf[:0])
return string(r.buf)
}
// ColumnBytes renders one column's searchable text into a reused buffer, so
// search matches what is on screen rather than the raw JSON.
func (r *Record) ColumnBytes(c Column) []byte {
r.buf = r.buf[:0]
switch c {
case ColTime:
r.buf = append(r.buf, StampText(r.Meta)...)
case ColTick:
r.buf = strconv.AppendUint(r.buf, uint64(r.Meta.Tick), 10)
case ColSub:
r.buf = append(r.buf, r.Sub...)
case ColMsg:
r.buf = r.appendMsg(r.buf)
case ColFields:
r.buf = r.appendFields(r.buf)
default:
r.buf = append(r.buf, StampText(r.Meta)...)
r.buf = append(r.buf, ' ')
r.buf = append(r.buf, r.Sub...)
r.buf = append(r.buf, ' ')
r.buf = r.appendMsg(r.buf)
r.buf = append(r.buf, ' ')
r.buf = r.appendFields(r.buf)
}
return r.buf
}
func (r *Record) appendMsg(dst []byte) []byte {
if r.msgIdx < 0 {
return dst
}
return appendUnquoted(dst, r.Fields[r.msgIdx].Val)
}
func (r *Record) appendFields(dst []byte) []byte {
start := len(dst)
for i, f := range r.Fields {
if i == r.msgIdx {
continue
}
if len(dst) > start {
dst = append(dst, ' ')
}
dst = append(dst, f.Key...)
dst = append(dst, '=')
dst = r.appendDisplay(dst, f)
if len(dst)-start > fieldsTextCap {
break
}
}
return dst
}
// Display renders a field value: durations via the unit table, long float
// tails trimmed, everything else verbatim.
func (r *Record) Display(f Field) string {
return string(r.appendDisplay(nil, f))
}
func (r *Record) appendDisplay(dst []byte, f Field) []byte {
switch f.Kind {
case KStr:
return appendUnquoted(dst, f.Val)
case KNum:
if u := DurationUnit(f.Key); u != DurNone {
if v, ok := parseInt64(f.Val); ok {
return append(dst, FormatDuration(v, u)...)
}
}
return append(dst, trimFloat(f.Val)...)
}
return append(dst, f.Val...)
}
func appendUnquoted(dst, tok []byte) []byte {
in := strTok(tok)
if bytes.IndexByte(in, '\\') < 0 {
return append(dst, in...)
}
return append(dst, unquote(tok)...)
}
// trimFloat shortens 17-digit float tails to 6 significant digits.
func trimFloat(tok []byte) []byte {
dot := bytes.IndexByte(tok, '.')
if dot < 0 || len(tok)-dot <= 7 {
return tok
}
if v, err := strconv.ParseFloat(string(tok), 64); err == nil {
return strconv.AppendFloat(nil, v, 'g', 6, 64)
}
return tok
}
+314
View File
@@ -0,0 +1,314 @@
package logfile
import (
"bufio"
"io"
"os"
"path/filepath"
"sync"
)
const (
scanBufSize = 1 << 20
publishEach = 4096
)
// scanPart is one source's private scan output, merged after completion.
type scanPart struct {
metas []Meta
snaps []Snapshot
}
// Open indexes one or more files and starts background scanning. A single
// source grows the published view incrementally; several sources publish once,
// merged by timestamp, so a row index is stable for the life of the index.
func Open(paths ...string) (*Index, error) {
if len(paths) == 0 {
return nil, os.ErrInvalid
}
x := &Index{subN: newInterner(), msgN: newInterner()}
files := make([]*os.File, 0, len(paths))
for _, p := range paths {
f, err := os.Open(p)
if err != nil {
closeAll(files)
return nil, err
}
fi, err := f.Stat()
if err != nil {
f.Close()
closeAll(files)
return nil, err
}
abs := p
if a, err := filepath.Abs(p); err == nil {
abs = a
}
x.srcs = append(x.srcs, &Source{Path: abs, Name: filepath.Base(p), Size: fi.Size()})
files = append(files, f)
}
x.publish(nil, nil, nil, nil)
go x.scanAll(files)
return x, nil
}
func closeAll(fs []*os.File) {
for _, f := range fs {
f.Close()
}
}
// publish stores immutable slice headers; a later append reallocates rather
// than mutating what readers already hold.
func (x *Index) publish(metas []Meta, snaps []Snapshot, subs, msgs []string) {
x.metas.Store(&metas)
x.snaps.Store(&snaps)
x.subs.Store(&subs)
x.msgs.Store(&msgs)
}
func (x *Index) scanAll(files []*os.File) {
live := len(x.srcs) == 1
parts := make([]scanPart, len(x.srcs))
var wg sync.WaitGroup
for i := range x.srcs {
wg.Add(1)
go func(i int) {
defer wg.Done()
x.scanSource(uint16(i), files[i], &parts[i], live)
}(i)
}
wg.Wait()
if live {
x.publish(parts[0].metas, parts[0].snaps, x.subN.table(), x.msgN.table())
return
}
metas, snaps := mergeParts(parts)
x.publish(metas, snaps, x.subN.table(), x.msgN.table())
}
// scanSource indexes one file. In live mode it republishes as it goes.
func (x *Index) scanSource(src uint16, f *os.File, part *scanPart, live bool) {
s := x.srcs[src]
defer f.Close()
defer s.done.Store(true)
br := bufio.NewReaderSize(f, scanBufSize)
var (
line []byte
off int64
lastPub int
lastTS int64
curID uint32
curR uint32
curT uint32
haveSnap bool
)
// Estimated row count avoids repeated regrowth on multi-MB files.
if s.Size > 0 {
part.metas = make([]Meta, 0, int(s.Size/160)+64)
}
// closedSnaps excludes the still-growing last group: its Count is mutated
// in place, and readers must never observe a header containing it.
closedSnaps := func() []Snapshot {
if len(part.snaps) == 0 {
return nil
}
return part.snaps[:len(part.snaps)-1]
}
for {
var err error
line, err = readLine(br, line)
raw := trimEOL(line)
if len(raw) > 0 {
m := parseMeta(raw, off, src, x.subN, x.msgN)
if m.Flags&FlagMalformed != 0 {
s.bad.Add(1)
}
// Ordering key: a line without a usable stamp inherits the previous
// one and is rendered as unstamped.
if m.TS == 0 {
m.TS, m.Flags = lastTS, m.Flags|FlagNoTime
} else {
lastTS = m.TS
}
idx := uint32(len(part.metas))
// A stat record whose (run,tick) differs from the previous stat
// record opens a new group. Frame is excluded: it is stamped by the
// render goroutine and can change mid-snapshot.
if x.subN.table()[m.Sub] == SubStat {
if !haveSnap || curR != m.Run || curT != m.Tick {
part.snaps = append(part.snaps, Snapshot{
Head: idx, Run: m.Run, Tick: m.Tick, Frame: m.Frame, Src: src,
})
curID = uint32(len(part.snaps))
curR, curT, haveSnap = m.Run, m.Tick, true
m.Flags |= FlagSnapHead
}
part.snaps[curID-1].Count++
m.Snap = curID
}
part.metas = append(part.metas, m)
}
off += int64(len(line))
s.scanned.Store(off)
if live && len(part.metas)-lastPub >= publishEach {
x.publish(part.metas, closedSnaps(), x.subN.table(), x.msgN.table())
lastPub = len(part.metas)
}
if err != nil {
if err != io.EOF {
s.failure.Store(&scanErr{err})
}
break
}
}
s.scanned.Store(off)
}
// mergeParts interleaves per-source rows by timestamp and renumbers snapshot
// ids into one global space. Sources are individually monotonic, so one linear
// k-way pass suffices.
func mergeParts(parts []scanPart) ([]Meta, []Snapshot) {
total := 0
base := make([]uint32, len(parts))
var snaps []Snapshot
for i := range parts {
total += len(parts[i].metas)
base[i] = uint32(len(snaps))
snaps = append(snaps, parts[i].snaps...)
}
out := make([]Meta, 0, total)
cur := make([]int, len(parts))
for {
best := -1
for i := range parts {
if cur[i] >= len(parts[i].metas) {
continue
}
if best < 0 || parts[i].metas[cur[i]].TS < parts[best].metas[cur[best]].TS {
best = i
}
}
if best < 0 {
break
}
m := parts[best].metas[cur[best]]
cur[best]++
if m.Snap != 0 {
m.Snap += base[best]
if m.Flags&FlagSnapHead != 0 {
snaps[m.Snap-1].Head = uint32(len(out))
}
}
out = append(out, m)
}
return out, snaps
}
// readLine appends the next line, terminator included, into buf.
func readLine(br *bufio.Reader, buf []byte) ([]byte, error) {
buf = buf[:0]
for {
chunk, err := br.ReadSlice('\n')
buf = append(buf, chunk...)
if err == bufio.ErrBufferFull {
continue
}
return buf, err
}
}
func trimEOL(b []byte) []byte {
for len(b) > 0 && (b[len(b)-1] == '\n' || b[len(b)-1] == '\r') {
b = b[:len(b)-1]
}
return b
}
// parseMeta extracts the indexed fields from one line. Unparseable lines are
// flagged and kept, never dropped.
func parseMeta(line []byte, off int64, src uint16, subN, msgN *interner) Meta {
m := Meta{Off: off, Len: uint32(len(line)), Src: src, Lvl: LevelBad, Flags: FlagMalformed}
i := skipSpace(line, 0)
if i >= len(line) || line[i] != '{' {
return m
}
ok := eachField(line, i, func(k, v []byte, kind byte) bool {
switch string(k) {
case "time":
if kind == KStr {
if ns, good := parseRFC3339Nano(strTok(v)); good {
m.TS = ns
}
}
case "level":
if kind == KStr {
m.Lvl = ParseLevel(strTok(v))
}
case "sub":
if kind == KStr {
if id := subN.intern(strTok(v)); id <= 0xffff {
m.Sub = uint16(id)
}
}
case "run":
m.Run = parseUint32(v)
case "tick":
m.Tick = parseUint32(v)
case "frame":
m.Frame = parseUint32(v)
case "trace":
if kind == KStr && len(v) > 2 {
m.Flags |= FlagTrace
}
case "fields":
if kind == KObj {
m.Msg = msgN.intern(discriminator(v))
}
}
return true
})
if ok {
m.Flags &^= FlagMalformed
}
return m
}
// discriminator returns fields.msg, falling back to fields.type for records
// that omit msg. Returns nil when neither is present; that interns to id 0.
func discriminator(fields []byte) []byte {
var msg, typ []byte
eachField(fields, 0, func(k, v []byte, kind byte) bool {
if kind != KStr {
return true
}
switch string(k) {
case "msg":
msg = strTok(v)
return false // msg wins and is always first; stop scanning
case "type":
typ = strTok(v)
}
return true
})
if msg != nil {
return msg
}
return typ
}
+162
View File
@@ -0,0 +1,162 @@
package logfile
import (
"time"
)
// Level is record severity. LevelTrace..LevelError are ordered for threshold
// filtering; LevelProc and LevelBad sit outside that order.
type Level uint8
const (
LevelTrace Level = iota
LevelDebug
LevelInfo
LevelWarn
LevelError
LevelProc
LevelBad
LevelCount
)
// OrderedCount is the number of threshold-comparable levels.
const OrderedCount = int(LevelError) + 1
var levelName = [LevelCount]string{"TRACE", "DEBUG", "INFO", "WARN", "ERROR", "PROC", "BAD"}
var levelInitial = [LevelCount]byte{'T', 'D', 'I', 'W', 'E', 'P', 'B'}
func (l Level) String() string {
if l < LevelCount {
return levelName[l]
}
return "?"
}
// Initial returns the single character used to toggle the level.
func (l Level) Initial() byte {
if l < LevelCount {
return levelInitial[l]
}
return '?'
}
// ParseLevel maps a level token to a Level; unknown tokens are LevelBad.
func ParseLevel(b []byte) Level {
switch string(b) {
case "TRACE":
return LevelTrace
case "DEBUG":
return LevelDebug
case "INFO":
return LevelInfo
case "WARN":
return LevelWarn
case "ERROR":
return LevelError
case "PROC":
return LevelProc
}
return LevelBad
}
// LevelByInitial resolves a toggle character to a Level.
func LevelByInitial(c byte) (Level, bool) {
for i := Level(0); i < LevelCount; i++ {
if levelInitial[i] == c {
return i, true
}
}
return LevelBad, false
}
// SubStat marks stat snapshot records.
const SubStat = "stat"
// StampText renders a record's wall-clock stamp, or a placeholder when the
// line carried no usable time and inherited one for ordering.
func StampText(m Meta) string {
if m.Flags&FlagNoTime != 0 {
return "--:--:--.---"
}
return FormatTS(m.TS)
}
// KnownSubs is the closed subsystem vocabulary; ad-hoc taps are also accepted.
var KnownSubs = []string{
"app", "service", "fsm", "event", "dispatch", "push",
"input", "stat", "rec", "lock", "race", "crash",
}
// DurUnit is the time unit implied by a metric key's suffix.
type DurUnit uint8
const (
DurNone DurUnit = iota
DurNs
DurUs
DurMs
)
// durKeys maps a key name to the unit it implies. Order matters: the first
// match wins, so unit suffixes are tested after the bare duration names.
var durKeys = []struct {
name string
unit DurUnit
}{
{"timer", DurNs}, {"duration", DurNs}, {"elapsed", DurNs}, {"remaining", DurNs},
{"ns", DurNs}, {"us", DurUs}, {"ms", DurMs},
}
// DurationUnit reports the duration unit implied by a field key.
func DurationUnit(key string) DurUnit {
for _, d := range durKeys {
if hasKeySegment(key, d.name) {
return d.unit
}
}
return DurNone
}
// hasKeySegment matches name as the whole key or as its trailing '.'/'_'
// segment: "elapsed", "max_duration" and "fsm.elapsed" match, "populations"
// does not match "ns".
func hasKeySegment(key, name string) bool {
if len(key) < len(name) || key[len(key)-len(name):] != name {
return false
}
if len(key) == len(name) {
return true
}
c := key[len(key)-len(name)-1]
return c == '.' || c == '_'
}
// FormatDuration renders v, expressed in unit u, as a human duration.
func FormatDuration(v int64, u DurUnit) string {
switch u {
case DurNs:
return time.Duration(v).String()
case DurUs:
return (time.Duration(v) * time.Microsecond).String()
case DurMs:
return (time.Duration(v) * time.Millisecond).String()
}
return ""
}
// FormatTS renders unix nanoseconds as a local wall-clock stamp.
func FormatTS(ns int64) string {
if ns == 0 {
return "--:--:--.---"
}
return time.Unix(0, ns).Format("15:04:05.000")
}
// Dash renders empty vocabulary values: records without sub, or without a
// fields.msg/type discriminator.
func Dash(s string) string {
if s == "" {
return "-"
}
return s
}
+84
View File
@@ -0,0 +1,84 @@
package logfile
// parseRFC3339Nano parses an RFC3339 stamp with optional fractional seconds
// into unix nanoseconds. Hand-rolled to keep the index pass allocation-free.
func parseRFC3339Nano(b []byte) (int64, bool) {
if len(b) < 19 {
return 0, false
}
if b[4] != '-' || b[7] != '-' || (b[10] != 'T' && b[10] != 't') || b[13] != ':' || b[16] != ':' {
return 0, false
}
y, ok1 := numField(b[0:4])
mo, ok2 := numField(b[5:7])
d, ok3 := numField(b[8:10])
h, ok4 := numField(b[11:13])
mi, ok5 := numField(b[14:16])
s, ok6 := numField(b[17:19])
if !ok1 || !ok2 || !ok3 || !ok4 || !ok5 || !ok6 {
return 0, false
}
i := 19
var frac int64
if i < len(b) && b[i] == '.' {
scale := int64(100000000)
for i++; i < len(b) && b[i] >= '0' && b[i] <= '9'; i++ {
if scale > 0 {
frac += int64(b[i]-'0') * scale
scale /= 10
}
}
}
var offSec int64
if i < len(b) {
switch b[i] {
case 'Z', 'z':
case '+', '-':
if i+6 > len(b) || b[i+3] != ':' {
return 0, false
}
oh, okh := numField(b[i+1 : i+3])
om, okm := numField(b[i+4 : i+6])
if !okh || !okm {
return 0, false
}
offSec = int64(oh)*3600 + int64(om)*60
if b[i] == '-' {
offSec = -offSec
}
}
}
sec := daysFromCivil(y, mo, d)*86400 + int64(h)*3600 + int64(mi)*60 + int64(s) - offSec
return sec*1e9 + frac, true
}
func numField(b []byte) (int, bool) {
v := 0
for _, c := range b {
if c < '0' || c > '9' {
return 0, false
}
v = v*10 + int(c-'0')
}
return v, true
}
// daysFromCivil returns days since 1970-01-01 (Hinnant's civil-date algorithm).
func daysFromCivil(y, m, d int) int64 {
if m <= 2 {
y--
}
era := y
if era < 0 {
era -= 399
}
era /= 400
yoe := y - era*400
mp := (m + 9) % 12
doy := (153*mp+2)/5 + d - 1
doe := yoe*365 + yoe/4 - yoe/100 + doy
return int64(era)*146097 + int64(doe) - 719468
}
+111
View File
@@ -0,0 +1,111 @@
package ui
import "github.com/lixenwraith/terminal/tui"
// Pane identifies a region of the frame.
type Pane uint8
const (
PaneNone Pane = iota
PaneHeader
PaneList
PaneDetail
PaneStatus
PaneFooter
)
// Dir is the split axis of a container node.
type Dir uint8
const (
Vertical Dir = iota // children stacked top to bottom
Horizontal // children placed left to right
)
// Node is either a leaf bound to a Pane or a container. Layout is data:
// changing pane count or arrangement edits this tree only.
type Node struct {
Pane Pane
Dir Dir
Fixed int // size along the parent axis, 0 = share by Weight
Weight float64
Children []Node
}
// Layout is the root of the pane tree.
type Layout struct{ Root Node }
// DefaultLayout is the phase-1 layout: header, list|detail body, status, footer.
func DefaultLayout() Layout {
return Layout{Root: Node{Dir: Vertical, Children: []Node{
{Pane: PaneHeader, Fixed: 1},
{Dir: Horizontal, Weight: 1, Children: []Node{
{Pane: PaneList, Weight: 0.62},
{Pane: PaneDetail, Weight: 0.38},
}},
{Pane: PaneStatus, Fixed: 1},
{Pane: PaneFooter, Fixed: 1},
}}}
}
// Resolve assigns a region to every leaf pane.
func (l Layout) Resolve(r tui.Region) map[Pane]tui.Region {
out := make(map[Pane]tui.Region, 8)
place(l.Root, r, out)
return out
}
func place(n Node, r tui.Region, out map[Pane]tui.Region) {
if len(n.Children) == 0 {
if n.Pane != PaneNone {
out[n.Pane] = r
}
return
}
total := r.H
if n.Dir == Horizontal {
total = r.W
}
sizes := share(n.Children, total)
pos := 0
for i, c := range n.Children {
var sub tui.Region
if n.Dir == Horizontal {
sub = r.Sub(pos, 0, sizes[i], r.H)
} else {
sub = r.Sub(0, pos, r.W, sizes[i])
}
place(c, sub, out)
pos += sizes[i]
}
}
// share splits total between fixed and weighted children; the rounding
// remainder goes to the last weighted child.
func share(cs []Node, total int) []int {
sizes := make([]int, len(cs))
rest, sum := total, 0.0
for i, c := range cs {
if c.Fixed > 0 {
s := min(c.Fixed, max(rest, 0))
sizes[i], rest = s, rest-s
} else {
sum += c.Weight
}
}
if sum <= 0 {
sum = 1
}
lastW, acc := -1, 0
for i, c := range cs {
if c.Fixed > 0 {
continue
}
s := int(float64(rest) * c.Weight / sum)
sizes[i], acc, lastW = s, acc+s, i
}
if lastW >= 0 {
sizes[lastW] += rest - acc
}
return sizes
}
+107
View File
@@ -0,0 +1,107 @@
package ui
import (
"github.com/lixenwraith/terminal"
"github.com/lixenwraith/terminal/tui"
)
// Panel is a modal overlay with a scrollable body. It owns the frame, the
// scroll state and the scrollbar; the caller renders rows into the region
// Render returns, starting at Scroll.
type Panel struct {
Title string
Hint string
Status string // line inside the bottom of the frame, e.g. a full filename
W, H int // 0 = 80% of the screen
Cursor bool // track a selected row rather than scrolling freely
Sel int
Scroll int
Rows int // content rows, set by the caller before Render
View int // visible rows, set by Render
}
// Move advances the selection, or the scroll offset when Cursor is unset.
func (p *Panel) Move(d int) {
if p.Cursor {
p.Sel += d
} else {
p.Scroll += d
}
p.clamp()
}
// Page moves by n screens.
func (p *Panel) Page(n int) { p.Move(n * max(1, p.View)) }
// Half moves by n half screens.
func (p *Panel) Half(n int) { p.Move(n * max(1, p.View/2)) }
// First jumps to the top.
func (p *Panel) First() { p.Sel, p.Scroll = 0, 0 }
// Last jumps to the bottom.
func (p *Panel) Last() {
p.Sel, p.Scroll = p.Rows-1, p.Rows
p.clamp()
}
// Reset returns the panel to the top, for new content.
func (p *Panel) Reset() { p.Sel, p.Scroll = 0, 0 }
func (p *Panel) clamp() {
if p.Rows <= 0 {
p.Sel, p.Scroll = 0, 0
return
}
p.Sel = tui.ClampCursor(p.Sel, p.Rows)
if p.Cursor {
p.Scroll = tui.AdjustScroll(p.Sel, p.Scroll, p.View, p.Rows)
}
p.Scroll = tui.ClampScroll(p.Scroll, p.View, p.Rows)
}
// Render draws the frame and returns the body region, scrollbar excluded.
// A zero-width result means the screen is too small to show the panel.
func (p *Panel) Render(root tui.Region, th *Theme) tui.Region {
w, h := p.W, p.H
if w <= 0 {
w = root.W * 80 / 100
}
if h <= 0 {
h = root.H * 80 / 100
}
w, h = min(w, root.W-4), min(h, root.H-2)
if w < 24 || h < 6 {
return tui.Region{}
}
t, hint := p.Title, p.Hint
if t != "" {
t = " " + t + " "
}
if hint != "" {
hint = " " + hint + " "
}
content := tui.Center(root, w, h).Modal(tui.ModalOpts{
Title: t, Hint: hint, Border: tui.LineDouble,
BorderFg: th.Accent, TitleFg: th.HeaderFg, HintFg: th.HintFg, Bg: th.FocusBg,
})
body := content
if p.Status != "" && content.H > 3 {
body = content.Sub(0, 0, content.W, content.H-2)
content.HLine(content.H-2, tui.LineSingle, th.Border)
content.Text(0, content.H-1, tui.Truncate(p.Status, content.W),
th.HintFg, th.FocusBg, terminal.AttrDim)
}
p.View = body.H
p.clamp()
if p.Rows > body.H && body.W > 2 {
body.Sub(body.W-1, 0, 1, body.H).ScrollBar(0, p.Scroll, body.H, p.Rows, th.Border)
body = body.Sub(0, 0, body.W-1, body.H)
}
return body
}
+73
View File
@@ -0,0 +1,73 @@
package ui
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal/tui"
"github.com/lixenwraith/vif-log/internal/logfile"
)
// Theme extends the tui theme with log-specific colors.
type Theme struct {
tui.Theme
Accent color.RGB
Accent2 color.RGB
Level [logfile.LevelCount]color.RGB
KeyFg color.RGB
NumFg color.RGB
StrFg color.RGB
SnapFg color.RGB
}
// DefaultTheme is the built-in theme.
var DefaultTheme = Theme{
Theme: tui.Theme{
Bg: color.Gunmetal, Fg: color.RGB{R: 192, G: 202, B: 245},
FocusBg: color.DarkSlate, CursorBg: color.RGB{R: 45, G: 50, B: 80},
Selected: color.RGB{R: 158, G: 206, B: 106}, Unselected: color.RGB{R: 86, G: 95, B: 137},
Partial: color.RGB{R: 125, G: 207, B: 255}, Error: color.RGB{R: 247, G: 118, B: 142},
Warning: color.RGB{R: 224, G: 175, B: 104}, Border: color.RGB{R: 59, G: 66, B: 97},
HeaderBg: color.RGB{R: 22, G: 22, B: 30}, HeaderFg: color.RGB{R: 192, G: 202, B: 245},
StatusFg: color.RGB{R: 140, G: 152, B: 200}, HintFg: color.RGB{R: 86, G: 95, B: 137},
InputBg: color.RGB{R: 31, G: 32, B: 46},
},
Accent: color.RGB{R: 122, G: 162, B: 247},
Accent2: color.RGB{R: 187, G: 154, B: 247},
Level: [logfile.LevelCount]color.RGB{
logfile.LevelTrace: {R: 100, G: 108, B: 140},
logfile.LevelDebug: {R: 125, G: 207, B: 255},
logfile.LevelInfo: {R: 158, G: 206, B: 106},
logfile.LevelWarn: {R: 224, G: 175, B: 104},
logfile.LevelError: {R: 247, G: 118, B: 142},
logfile.LevelProc: {R: 187, G: 154, B: 247},
logfile.LevelBad: {R: 255, G: 100, B: 100},
},
KeyFg: color.RGB{R: 140, G: 152, B: 200},
NumFg: color.RGB{R: 224, G: 175, B: 104},
StrFg: color.RGB{R: 158, G: 206, B: 106},
SnapFg: color.RGB{R: 125, G: 207, B: 255},
}
// subPalette gives ad-hoc subsystem tags stable colors without a lookup table.
var subPalette = []color.RGB{
{R: 122, G: 162, B: 247}, {R: 158, G: 206, B: 106}, {R: 224, G: 175, B: 104},
{R: 187, G: 154, B: 247}, {R: 125, G: 207, B: 255}, {R: 247, G: 118, B: 142},
{R: 180, G: 220, B: 200}, {R: 220, G: 200, B: 140},
}
// SubColor returns a stable color for a subsystem tag.
func (t *Theme) SubColor(s string) color.RGB {
if s == "" {
return t.StatusFg
}
h := uint32(2166136261)
for i := 0; i < len(s); i++ {
h = (h ^ uint32(s[i])) * 16777619
}
return subPalette[h%uint32(len(subPalette))]
}
// SrcColor returns a stable color for a source id, drawn from the same palette
// so gutter marks and subsystem tags share a visual language.
func (t *Theme) SrcColor(id int) color.RGB {
return subPalette[uint32(id)%uint32(len(subPalette))]
}