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
+11
View File
@@ -0,0 +1,11 @@
.idea
bin/
dev/
logs/
log/
cmake-build-*/
build.sh
vi-fighter
catalog.txt
combined.txt
web/*.wasm
+28
View File
@@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2026, Lixen Wraith
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+4
View File
@@ -0,0 +1,4 @@
.PHONY: all
all:
go build -o bin/vif-log cmd/vif-log/main.go
+79
View File
@@ -0,0 +1,79 @@
# vif-log
A high-performance, terminal-based JSONL diagnostic log viewer engineered for the `vi-fighter` ECS game engine.
Designed to parse and navigate high-frequency ECS event streams and state snapshots with minimal overhead. Features zero-allocation index passes, lazy-loaded line evaluation, and asynchronous chronological merging of multiple log sources.
## Core Architecture
* **Zero-Alloc Indexing**: Hand-rolled RFC3339Nano parser and JSON tokenizer (`internal/logfile`). Constructs a pointer-free 48-byte `Meta` struct per record. Raw JSON strings are interned to bitset-friendly integer IDs.
* **Asynchronous Render Pipeline**: Background indexers publish lock-free slice headers to the render thread. The UI remains responsive during multi-gigabyte ingestion.
* **Multi-Source K-Way Merge**: Loads multiple `.jsonl` files (e.g., cross-network client/server logs) and performs a stable chronological merge using nanosecond timestamps without mutating the row index.
* **Smart ECS Snapshotting**: High-frequency telemetry (ECS component states pushed per-tick) are automatically collapsed into single navigable rows (`Filter.Collapse`), expanding on demand.
* **Deferred Evaluation Stack**: The filter chain evaluates index-resident predicates (Tick, Run, Subsystem, Level) first. Costly operations (Regex over raw JSON fields) only trigger for surviving records that enter the sliding read window.
## Build & Run
Requires Go 1.26+ (Wayland environment natively supported via underlying TUI library).
```bash
go build -o vif-log ./cmd/vif-log
```
## Usage
```bash
# Open a specific log file or directory
vif-log path/to/run.jsonl
vif-log ./logs/
# Multi-file merge
vif-log server.jsonl client.jsonl
# Pre-seed filter stack
vif-log -f level:>=WARN -f sub:^(fsm|event)$ -f tick:1000-5000 ./logs/
```
### Predicates (Filters)
Filters stack. Use `\` in the UI or `-f` via CLI.
* `level`: Exact match (`IWE`) or threshold (`>=WARN`).
* `sub` / `msg`: Smart-case regex evaluated against the interned vocabulary.
* `tick` / `run`: Numeric spans (`100-200`, `150-`, `-50`).
* `fields`: Smart-case regex evaluated over the parsed JSON fields.
* `find`: Column-scoped regex search (evaluates against time, tick, sub, msg, or fields).
## Keybindings
### Navigation & UI
| Key | Action |
| :--- | :--- |
| `j` / `k` | Move cursor down/up |
| `gg` / `G` | Jump to first/last record |
| `Ctrl+d` / `Ctrl+u` | Half-page down/up |
| `Tab` / `Shift+Tab`| Cycle column focus |
| `s` | Sort by focused column (asc/desc/off) |
| `J` / `K` | Scroll detail pane down/up |
| `Enter` | Expand/collapse ECS stat snapshot |
| `n` / `N` | Jump to next/prev snapshot head |
| `Ctrl+l` | Force redraw |
### Search & Filtering
| Key | Action |
| :--- | :--- |
| `/` | Regex search in the currently focused column |
| `\` | Add/replace a filter in the stack (e.g., `msg:transition`) |
| `f` / `F` | Follow: Jump to next/prev record with identical `sub`/`msg`/context |
| `t` `d` `i` `w` `e` `p` `b` | Toggle visibility of exact log levels |
| `1`-`5` | Set minimum level threshold (1=TRACE, 5=ERROR) |
| `<` / `>` | Lower/Raise level threshold |
| `Esc` | Clear search and dynamic filters |
### Buffer & File Management
| Key | Action |
| :--- | :--- |
| `Space` | Toggle pin on current record |
| `P` | Toggle pinned-only view |
| `C` | Clear all pins |
| `o` | Open file browser (supports multi-select with `Space`) |
| `x` | Export current view (or pins) to a new `.jsonl` file |
+83
View File
@@ -0,0 +1,83 @@
// vif-log — terminal viewer for vi-fighter JSONL diagnostic logs.
package main
import (
"flag"
"fmt"
"os"
"strings"
"time"
"github.com/lixenwraith/terminal"
"github.com/lixenwraith/vif-log/internal/app"
"github.com/lixenwraith/vif-log/internal/filter"
)
const tickInterval = 50 * time.Millisecond // 20 fps clock
// filterSpec collects repeatable -f kind:arg flags.
type filterSpec []string
func (f *filterSpec) String() string { return strings.Join(*f, " ") }
func (f *filterSpec) Set(v string) error {
*f = append(*f, v)
return nil
}
func main() {
var specs filterSpec
flag.Var(&specs, "f", "filter, repeatable: kind:regexp (sub msg tick run level find fields)")
flag.Usage = func() {
fmt.Fprintln(os.Stderr, "usage: vif-log [-f kind:arg]... [file.jsonl... | directory]")
flag.PrintDefaults()
fmt.Fprintln(os.Stderr, "\nfilters:")
for _, d := range filter.Kinds() {
fmt.Fprintf(os.Stderr, " %-6s %s\n", d.Kind, d.Help)
}
}
flag.Parse()
term := terminal.New()
defer func() {
if r := recover(); r != nil {
terminal.EmergencyReset(os.Stdout)
panic(r)
}
}()
if err := term.Init(); err != nil {
fmt.Fprintln(os.Stderr, "terminal init:", err)
os.Exit(1)
}
a, err := app.New(term, flag.Args(), specs)
if err != nil {
term.Fini()
fmt.Fprintln(os.Stderr, "init:", err)
os.Exit(1)
}
defer a.Close()
// Synthetic tick: the input reader never emits {EventKey, KeyNone}, so it is
// a collision-free wake-up for index progress and incremental filtering.
done := make(chan struct{})
go func() {
t := time.NewTicker(tickInterval)
defer t.Stop()
for {
select {
case <-done:
return
case <-t.C:
term.PostEvent(terminal.Event{Type: terminal.EventKey, Key: terminal.KeyNone})
}
}
}()
for !a.Quit() {
a.Render()
a.Handle(term.PollEvent())
}
close(done)
term.Fini()
}
+13
View File
@@ -0,0 +1,13 @@
module github.com/lixenwraith/vif-log
go 1.26.5
require (
github.com/lixenwraith/color v0.0.0-20260719094342-615e11bc7897
github.com/lixenwraith/terminal v0.0.0-20260801131017-3d9631d1dbda
)
require (
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
)
+8
View File
@@ -0,0 +1,8 @@
github.com/lixenwraith/color v0.0.0-20260719094342-615e11bc7897 h1:YkTK1vIzG6sqKRKaeUdkPyMT2laithY1d8tCqyId77U=
github.com/lixenwraith/color v0.0.0-20260719094342-615e11bc7897/go.mod h1:p02MsAGqlmZu3sc6BPYCKmaedF+lqBoijnuu5cOrYeo=
github.com/lixenwraith/terminal v0.0.0-20260801131017-3d9631d1dbda h1:xaNK5HHVBWCvqr5qsq/KT5bbj77MO5//yZi8sDm4t1M=
github.com/lixenwraith/terminal v0.0.0-20260801131017-3d9631d1dbda/go.mod h1:4rwKdMTck5HljHtRyg+ZyQGIU1Xtgdvy8IVZY9t2fL8=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
+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))]
}