v0.1.0 initial commit
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
||||
.idea
|
||||
bin/
|
||||
dev/
|
||||
logs/
|
||||
log/
|
||||
cmake-build-*/
|
||||
build.sh
|
||||
vi-fighter
|
||||
catalog.txt
|
||||
combined.txt
|
||||
web/*.wasm
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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, ' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))]
|
||||
}
|
||||
Reference in New Issue
Block a user