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"
}