From e0a876b58753f2539579bffe46d84688ccd57862 Mon Sep 17 00:00:00 2001 From: Hiram Chirino Date: Mon, 16 Sep 2024 01:32:10 -0400 Subject: [PATCH] feat: Fast stdin display, lower memory usage, follow/reverse mode, home/end key support. long help support (#85) Signed-off-by: Hiram Chirino Co-authored-by: Maksym Kryvchun --- .gitignore | 1 + .golangci.json | 7 +- Makefile | 5 + README.md | 23 +-- assets/jlv.log | 1 + cmd/jlv/main.go | 53 +++--- cmd/jlv/main_test.go | 25 +-- cmd/jlv/stdin_reader.go | 24 +++ cmd/jlv/stdin_reader_mock.go | 30 ++++ example.jlv.jsonc | 6 - go.mod | 9 +- go.sum | 10 +- internal/app/app.go | 27 ++- internal/app/app_test.go | 17 +- internal/app/helper.go | 89 +++------- internal/app/keymap.go | 41 ++++- internal/app/lazytable.go | 159 ++++++++++++++++-- internal/app/logstable.go | 66 +++++--- internal/app/state.go | 3 +- internal/app/stateerror.go | 10 +- internal/app/stateerror_test.go | 25 ++- internal/app/statefiltered.go | 77 ++++----- internal/app/statefiltered_test.go | 83 ++++----- internal/app/statefiltering.go | 14 +- internal/app/statefiltering_test.go | 88 +++++----- internal/app/stateinitial.go | 16 +- internal/app/stateinitial_test.go | 22 ++- internal/app/stateloaded.go | 146 ++++++++-------- internal/app/stateloaded_test.go | 138 ++++----------- internal/app/stateviewrow.go | 25 +-- internal/app/stateviewrow_test.go | 27 +-- internal/pkg/config/config.go | 11 -- internal/pkg/config/config_test.go | 111 +++++------- internal/pkg/events/events.go | 26 ++- internal/pkg/source/entry.go | 75 ++++++--- internal/pkg/source/entry_test.go | 104 +++++------- internal/pkg/source/fileinput/fileinput.go | 29 ---- .../pkg/source/fileinput/fileinput_test.go | 56 ------ internal/pkg/source/input.go | 15 -- .../pkg/source/readerinput/readerinput.go | 90 ---------- .../source/readerinput/readerinput_test.go | 139 --------------- internal/pkg/source/source.go | 157 ++++++++++++++--- internal/pkg/source/source_test.go | 30 ++-- internal/pkg/source/sreamer.go | 99 +++++++++++ 44 files changed, 1094 insertions(+), 1115 deletions(-) create mode 100644 assets/jlv.log create mode 100644 cmd/jlv/stdin_reader.go create mode 100644 cmd/jlv/stdin_reader_mock.go delete mode 100644 internal/pkg/source/fileinput/fileinput.go delete mode 100644 internal/pkg/source/fileinput/fileinput_test.go delete mode 100644 internal/pkg/source/input.go delete mode 100644 internal/pkg/source/readerinput/readerinput.go delete mode 100644 internal/pkg/source/readerinput/readerinput_test.go create mode 100644 internal/pkg/source/sreamer.go diff --git a/.gitignore b/.gitignore index 1f5f849..d021cbb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ bin # IDE .vscode +.idea # Test binary, built with `go test -c` *.test diff --git a/.golangci.json b/.golangci.json index a8428bf..b808b02 100644 --- a/.golangci.json +++ b/.golangci.json @@ -25,13 +25,18 @@ "ireturn", "gomoddirectives", "execinquery", - "tagalign" + "tagalign", + "mnd", + "nlreturn" ] }, "linters-settings": { "goimports": { "local-prefixes": "github.com/hedhyw/json-log-viewer/" }, + "cyclop": { + "max-complexity": 15 + }, "revive": {} } } \ No newline at end of file diff --git a/Makefile b/Makefile index 675b412..10629ac 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,11 @@ lint: bin/golangci-lint-${GOLANG_CI_LINT_VER} ./bin/golangci-lint-${GOLANG_CI_LINT_VER} run .PHONY: lint +fix: bin/golangci-lint-${GOLANG_CI_LINT_VER} + gofumpt -l -w . + ./bin/golangci-lint-${GOLANG_CI_LINT_VER} run --fix +.PHONY: lint-fix + test: go test \ -coverpkg=${COVER_PACKAGES} \ diff --git a/README.md b/README.md index 8180212..aefb653 100644 --- a/README.md +++ b/README.md @@ -75,16 +75,19 @@ docker logs -f 000000000000 2>&1 | jlv ### Hotkeys -| Key | Action | -| ------ | -------------- | -| Enter | Open/Close log | -| F | Filter | -| Ctrl+C | Exit | -| F10 | Exit | -| Esc | Back | -| ↑↓ | Navigation | - -> \[↑\] Click Up on the first row to reload the file. +| Key | Action | +|--------|-------------------| +| Enter | Open log | +| Esc | Back | +| F | Filter | +| R | Reverse | +| Ctrl+C | Exit | +| F10 | Exit | +| ↑↓ | Line Up / Down | +| Home | Navigate to Start | +| End | Navigate to End | + +> Attempting to navigate past the last line in the log will put you in follow mode. ## Install diff --git a/assets/jlv.log b/assets/jlv.log new file mode 100644 index 0000000..c8b813d --- /dev/null +++ b/assets/jlv.log @@ -0,0 +1 @@ +time=2024-08-19T15:06:16.848-04:00 level=INFO msg="case <-eofEvent" diff --git a/cmd/jlv/main.go b/cmd/jlv/main.go index 14595f2..7e86ed6 100644 --- a/cmd/jlv/main.go +++ b/cmd/jlv/main.go @@ -1,10 +1,9 @@ package main import ( - "bytes" + "context" "flag" "fmt" - "io/fs" "os" "path" @@ -12,9 +11,8 @@ import ( "github.com/hedhyw/json-log-viewer/internal/app" "github.com/hedhyw/json-log-viewer/internal/pkg/config" + "github.com/hedhyw/json-log-viewer/internal/pkg/events" "github.com/hedhyw/json-log-viewer/internal/pkg/source" - "github.com/hedhyw/json-log-viewer/internal/pkg/source/fileinput" - "github.com/hedhyw/json-log-viewer/internal/pkg/source/readerinput" ) // version will be set on build. @@ -39,41 +37,54 @@ func main() { fatalf("Error reading config: %s\n", err) } - var sourceInput source.Input + fileName := "" + var inputSource *source.Source switch flag.NArg() { case 0: - sourceInput, err = getStdinSource(cfg, os.Stdin) + // Tee stdin to a temp file, so that we can + // lazy load the log entries using random access. + fileName = "-" + + stdIn, err := getStdinReader(os.Stdin) if err != nil { fatalf("Stdin: %s\n", err) } + + inputSource, err = source.Reader(stdIn, cfg) + if err != nil { + fatalf("Could not create temp flie: %s\n", err) + } + defer inputSource.Close() + case 1: - sourceInput = fileinput.New(flag.Arg(0)) + fileName = flag.Arg(0) + inputSource, err = source.File(fileName, cfg) + if err != nil { + fatalf("Could not create temp flie: %s\n", err) + } + defer inputSource.Close() + default: fatalf("Invalid arguments, usage: %s file.log\n", os.Args[0]) } - appModel := app.NewModel(sourceInput, cfg, version) + appModel := app.NewModel(fileName, cfg, version) program := tea.NewProgram(appModel, tea.WithInputTTY(), tea.WithAltScreen()) + inputSource.StartStreaming(context.Background(), func(entries source.LazyLogEntries, err error) { + if err != nil { + program.Send(events.ErrorOccuredMsg{Err: err}) + } else { + program.Send(events.LogEntriesUpdateMsg(entries)) + } + }) + if _, err := program.Run(); err != nil { fatalf("Error running program: %s\n", err) } } -func getStdinSource(cfg *config.Config, defaultInput fs.File) (source.Input, error) { - stat, err := defaultInput.Stat() - if err != nil { - return nil, fmt.Errorf("stat: %w", err) - } - - if stat.Mode()&os.ModeCharDevice != 0 { - return readerinput.New(bytes.NewReader(nil), cfg.StdinReadTimeout), nil - } - - return readerinput.New(defaultInput, cfg.StdinReadTimeout), nil -} - func fatalf(message string, args ...any) { fmt.Fprintf(os.Stderr, message, args...) os.Exit(1) diff --git a/cmd/jlv/main_test.go b/cmd/jlv/main_test.go index 5104a07..0ffbb5f 100644 --- a/cmd/jlv/main_test.go +++ b/cmd/jlv/main_test.go @@ -2,15 +2,12 @@ package main import ( "bytes" - "context" "errors" "io" "io/fs" "os" "testing" - "github.com/hedhyw/json-log-viewer/internal/pkg/config" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -18,8 +15,6 @@ import ( func TestGetStdinSource(t *testing.T) { t.Parallel() - ctx := context.Background() - t.Run("ModeNamedPipe", func(t *testing.T) { t.Parallel() @@ -32,15 +27,10 @@ func TestGetStdinSource(t *testing.T) { }, } - input, err := getStdinSource(config.GetDefaultConfig(), file) - require.NoError(t, err) - - readCloser, err := input.ReadCloser(ctx) + input, err := getStdinReader(file) require.NoError(t, err) - t.Cleanup(func() { assert.NoError(t, readCloser.Close()) }) - - data, err := io.ReadAll(readCloser) + data, err := io.ReadAll(input) require.NoError(t, err) assert.Equal(t, content, string(data)) }) @@ -55,15 +45,10 @@ func TestGetStdinSource(t *testing.T) { }, } - input, err := getStdinSource(config.GetDefaultConfig(), file) - require.NoError(t, err) - - readCloser, err := input.ReadCloser(ctx) + input, err := getStdinReader(file) require.NoError(t, err) - t.Cleanup(func() { assert.NoError(t, readCloser.Close()) }) - - data, err := io.ReadAll(readCloser) + data, err := io.ReadAll(input) require.NoError(t, err) assert.Empty(t, data) }) @@ -76,7 +61,7 @@ func TestGetStdinSource(t *testing.T) { file := fakeFile{ErrStat: errStat} - _, err := getStdinSource(config.GetDefaultConfig(), file) + _, err := getStdinReader(file) require.Error(t, err) require.ErrorIs(t, err, errStat) }) diff --git a/cmd/jlv/stdin_reader.go b/cmd/jlv/stdin_reader.go new file mode 100644 index 0000000..ed330d5 --- /dev/null +++ b/cmd/jlv/stdin_reader.go @@ -0,0 +1,24 @@ +//go:build !mock_stdin + +package main + +import ( + "bytes" + "fmt" + "io" + "io/fs" + "os" +) + +func getStdinReader(defaultInput fs.File) (io.Reader, error) { + stat, err := defaultInput.Stat() + if err != nil { + return nil, fmt.Errorf("stat: %w", err) + } + + if stat.Mode()&os.ModeCharDevice != 0 { + return bytes.NewReader(nil), nil + } + + return defaultInput, nil +} diff --git a/cmd/jlv/stdin_reader_mock.go b/cmd/jlv/stdin_reader_mock.go new file mode 100644 index 0000000..c842800 --- /dev/null +++ b/cmd/jlv/stdin_reader_mock.go @@ -0,0 +1,30 @@ +//go:build mock_stdin + +package main + +import ( + "fmt" + "io" + "io/fs" + "os" + "time" +) + +func getStdinReader(defaultInput fs.File) (io.Reader, error) { + r, w, err := os.Pipe() + if err != nil { + return nil, fmt.Errorf("pipe: %w", err) + } + go func() { + defer w.Close() + for i := 0; ; i++ { + _, err := w.Write([]byte(fmt.Sprintf(`{"message": "Line %d"} + `, i))) + if err != nil { + fatalf("Write failed: %s\n", err) + } + time.Sleep(10 * time.Millisecond) + } + }() + return r, nil +} diff --git a/example.jlv.jsonc b/example.jlv.jsonc index c7c2ebf..8a6a7ee 100644 --- a/example.jlv.jsonc +++ b/example.jlv.jsonc @@ -63,13 +63,7 @@ "50": "error", "60": "fatal" }, - // The number of rows to pre-render. - "prerenderRows": 100, - // The number nanoseconds between manual file reloads. - "reloadThreshold": 1000000000, // The maximum size of the file in bytes. // The rest of the file will be ignored. "maxFileSizeBytes": 1073741824, - // StdinReadTimeout is the timeout (in nanoseconds) of reading from the standart input. - "stdinReadTimeout": 1000000000 } \ No newline at end of file diff --git a/go.mod b/go.mod index 0b9cb1d..a567feb 100644 --- a/go.mod +++ b/go.mod @@ -2,15 +2,15 @@ module github.com/hedhyw/json-log-viewer go 1.22 -replace github.com/antonmedv/fx => github.com/hedhyw/fx v0.0.2 +replace github.com/antonmedv/fx => github.com/chirino/fx v0.0.0-20240818132837-248e67b184d9 replace github.com/charmbracelet/bubbles => github.com/hedhyw/bubbles v0.0.4 require ( - github.com/antonmedv/fx v0.0.0-20240428214715-6793ff4a0e59 + github.com/antonmedv/fx v0.0.0-20240807042048-dd653cf7bf83 github.com/charmbracelet/bubbles v0.18.0 github.com/charmbracelet/bubbletea v0.25.0 - github.com/charmbracelet/lipgloss v0.11.0 + github.com/charmbracelet/lipgloss v0.12.1 github.com/go-playground/validator/v10 v10.22.0 github.com/hedhyw/jsoncjson v1.1.0 github.com/muesli/reflow v0.3.0 @@ -21,12 +21,13 @@ require ( require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/x/ansi v0.1.2 // indirect + github.com/charmbracelet/x/ansi v0.1.4 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.4 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/hedhyw/semerr v0.6.7 // indirect github.com/kr/pretty v0.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect diff --git a/go.sum b/go.sum index c42faa4..28673e4 100644 --- a/go.sum +++ b/go.sum @@ -8,10 +8,16 @@ github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g= github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8= +github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs= +github.com/charmbracelet/lipgloss v0.12.1/go.mod h1:V2CiwIuhx9S1S1ZlADfOj9HmxeMAORuz5izHb0zGbB8= github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY= github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= +github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/exp/teatest v0.0.0-20230904163802-ca705a396e0f h1:kI7ZjLqp210CeIUKhjdLmtnc9UIVcfKSePgwGTxQ0J4= github.com/charmbracelet/x/exp/teatest v0.0.0-20230904163802-ca705a396e0f/go.mod h1:TckAxPtan3aJ5wbTgBkySpc50SZhXJRZ8PtYICnZJEw= +github.com/chirino/fx v0.0.0-20240818132837-248e67b184d9 h1:v5OXoBrEplJ65ylCZ0gZ9DCTB7EWd+SWIvVL9jxdPTc= +github.com/chirino/fx v0.0.0-20240818132837-248e67b184d9/go.mod h1:km/FnS8aa6d/z086KPzYtYzLv/lWMEnF275IS3wC0jE= github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -29,10 +35,10 @@ github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4 github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/hedhyw/bubbles v0.0.4 h1:4QZeYNa6KcE2CAszgQ07rSk4qd2EDj4FlH9Q3oECq5w= github.com/hedhyw/bubbles v0.0.4/go.mod h1:0B5SDVyyRXMteAgJRkYRJQ6bvsKtWdzeepp8rN+RhXQ= -github.com/hedhyw/fx v0.0.2 h1:btVjqV+CjHlpt6YZsLOK7jciBpS9TJy6nZHSoMxIhVU= -github.com/hedhyw/fx v0.0.2/go.mod h1:sxGcEYNS1KgU2wRDf7L2wNfpY+BeEw0vYhjSHdFQdHk= github.com/hedhyw/jsoncjson v1.1.0 h1:uw/aqmbSXAQNJHDPLb+DpwlPNzMREGIsrs+TIwPk+f0= github.com/hedhyw/jsoncjson v1.1.0/go.mod h1:++nXlbEXzRMcqkoDLvH5I/z5qBkacAWSZDt1u6osUPc= +github.com/hedhyw/semerr v0.6.7 h1:C9TaGpxJfbiiyyja+kFSZB9QK7vSNKc1RZPmWbXBmPI= +github.com/hedhyw/semerr v0.6.7/go.mod h1:GqxYzQ0igy0bi6pc0e38FScn9rQk1n4l+PuVKlQNMW4= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= diff --git a/internal/app/app.go b/internal/app/app.go index 89b45a0..1b29b60 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,28 +1,33 @@ package app import ( + "github.com/charmbracelet/bubbles/help" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/hedhyw/json-log-viewer/internal/pkg/config" "github.com/hedhyw/json-log-viewer/internal/pkg/source" + + "github.com/hedhyw/json-log-viewer/internal/pkg/config" ) // Application global state. type Application struct { - SourceInput source.Input - Config *config.Config + FileName string + Config *config.Config BaseStyle lipgloss.Style FooterStyle lipgloss.Style LastWindowSize tea.WindowSizeMsg + Entries source.LazyLogEntries + Version string - Version string + keys KeyMap + help help.Model } func newApplication( - sourceInput source.Input, + fileName string, config *config.Config, version string, ) Application { @@ -32,8 +37,8 @@ func newApplication( ) return Application{ - SourceInput: sourceInput, - Config: config, + FileName: fileName, + Config: config, BaseStyle: getBaseStyle(), FooterStyle: getFooterStyle(), @@ -44,15 +49,19 @@ func newApplication( }, Version: version, + keys: defaultKeys, + help: help.New(), } } // NewModel initializes a new application model. It accept the path // to the file with logs. func NewModel( - sourceInput source.Input, + fileName string, config *config.Config, version string, ) tea.Model { - return newStateInitial(newApplication(sourceInput, config, version)) + application := newApplication(fileName, config, version) + + return newStateInitial(&application) } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 195baa1..4b191db 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -4,14 +4,16 @@ import ( "errors" "testing" + "github.com/hedhyw/json-log-viewer/internal/pkg/events" + "github.com/hedhyw/json-log-viewer/internal/pkg/source" + "github.com/hedhyw/json-log-viewer/internal/pkg/tests" + "github.com/charmbracelet/bubbles/cursor" tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/require" "github.com/hedhyw/json-log-viewer/internal/app" "github.com/hedhyw/json-log-viewer/internal/pkg/config" - "github.com/hedhyw/json-log-viewer/internal/pkg/source/fileinput" - "github.com/hedhyw/json-log-viewer/internal/pkg/tests" ) const testVersion = "v0.0.1" @@ -21,8 +23,15 @@ func newTestModel(tb testing.TB, content []byte) tea.Model { testFile := tests.RequireCreateFile(tb, content) - model := app.NewModel(fileinput.New(testFile), config.GetDefaultConfig(), testVersion) - model = handleUpdate(model, model.Init()()) + inputSource, err := source.File(testFile, config.GetDefaultConfig()) + require.NoError(tb, err) + model := app.NewModel(testFile, config.GetDefaultConfig(), testVersion) + + entries, err := inputSource.ParseLogEntries() + require.NoError(tb, err) + model = handleUpdate(model, events.LogEntriesUpdateMsg(entries)) + + tb.Cleanup(func() { _ = inputSource.Close() }) return model } diff --git a/internal/app/helper.go b/internal/app/helper.go index d214da5..ce0b8f0 100644 --- a/internal/app/helper.go +++ b/internal/app/helper.go @@ -1,12 +1,8 @@ package app import ( - "context" - "errors" "fmt" - "runtime" "strings" - "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/table" @@ -18,55 +14,18 @@ import ( "github.com/hedhyw/json-log-viewer/internal/pkg/source" ) -type helper struct { - Application -} - -// LoadEntries reads and parses entries from the input source. -func (h helper) LoadEntries() tea.Msg { - logEntries, err := h.loadEntriesFromSourceInput() - if err != nil { - return events.ErrorOccuredMsg{Err: err} - } - - runtime.GC() - - return events.LogEntriesLoadedMsg(logEntries) -} - -func (h helper) loadEntriesFromSourceInput() (logEntries source.LazyLogEntries, err error) { - ctx := context.Background() - - readCloser, err := h.SourceInput.ReadCloser(ctx) - if err != nil { - return nil, fmt.Errorf("readcloser: %w", err) - } - - defer func() { err = errors.Join(err, readCloser.Close()) }() - - logEntries, err = source.ParseLogEntriesFromReader( - readCloser, - h.Config, - ) - if err != nil { - return nil, fmt.Errorf("reading logs: %w", err) - } - - return logEntries, nil -} - -func (h helper) getLogLevelStyle( +func (app *Application) getLogLevelStyle( logEntries source.LazyLogEntries, baseStyle lipgloss.Style, rowID int, ) lipgloss.Style { - if rowID < 0 || rowID >= len(logEntries) { + if rowID < 0 || rowID >= logEntries.Len() { return baseStyle } - entry := logEntries[rowID].LogEntry(h.Config) + entry := logEntries.Entries[rowID].LogEntry(logEntries.Seeker, app.Config) - color := getColorForLogLevel(h.getLogLevelFromLogEntry(entry)) + color := getColorForLogLevel(app.getLogLevelFromLogEntry(entry)) if color == "" { return baseStyle } @@ -75,12 +34,13 @@ func (h helper) getLogLevelStyle( } // Update application state. -func (h helper) Update(msg tea.Msg) helper { - if msg, ok := msg.(tea.WindowSizeMsg); ok { - h.LastWindowSize = msg +func (app *Application) Update(msg tea.Msg) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + app.LastWindowSize = msg + case events.LogEntriesUpdateMsg: + app.Entries = source.LazyLogEntries(msg) } - - return h } func getColorForLogLevel(level source.Level) lipgloss.Color { @@ -102,49 +62,46 @@ func getColorForLogLevel(level source.Level) lipgloss.Color { } } -func (h helper) getLogLevelFromLogEntry(logEntry source.LogEntry) source.Level { - return source.Level(getFieldByKind(h.Config, config.FieldKindLevel, logEntry)) +func (app *Application) getLogLevelFromLogEntry(logEntry source.LogEntry) source.Level { + return source.Level(getFieldByKind(app.Config, config.FieldKindLevel, logEntry)) } -func (h helper) handleErrorOccuredMsg(msg events.ErrorOccuredMsg) (tea.Model, tea.Cmd) { - return initializeModel(newStateError(h.Application, msg.Err)) +func (app *Application) handleErrorOccuredMsg(msg events.ErrorOccuredMsg) (tea.Model, tea.Cmd) { + return initializeModel(newStateError(app, msg.Err)) } -func (h helper) handleLogEntriesLoadedMsg( - msg events.LogEntriesLoadedMsg, - lastReloadAt time.Time, +func (app *Application) handleInitialLogEntriesLoadedMsg( + msg events.LogEntriesUpdateMsg, ) (tea.Model, tea.Cmd) { return initializeModel(newStateViewLogs( - h.Application, + app, source.LazyLogEntries(msg), - lastReloadAt, )) } -func (h helper) handleOpenJSONRowRequestedMsg( +func (app *Application) handleOpenJSONRowRequestedMsg( msg events.OpenJSONRowRequestedMsg, previousState stateModel, ) (tea.Model, tea.Cmd) { - if msg.Index < 0 || msg.Index >= len(msg.LogEntries) { + if msg.Index < 0 || msg.Index >= msg.LogEntries.Len() { return previousState, nil } - logEntry := msg.LogEntries[msg.Index] + logEntry := msg.LogEntries.Entries[msg.Index] return initializeModel(newStateViewRow( - h.Application, - logEntry.LogEntry(h.Config), + logEntry.LogEntry(msg.LogEntries.Seeker, app.Config), previousState, )) } -func (h helper) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { +func (app *Application) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { switch { case key.Matches(msg, defaultKeys.Exit): return tea.Quit case key.Matches(msg, defaultKeys.Filter): return events.FilterKeyClicked - case key.Matches(msg, defaultKeys.ToggleView): + case key.Matches(msg, defaultKeys.Open): return events.EnterKeyClicked case key.Matches(msg, defaultKeys.ToggleViewArrow): return events.ArrowRightKeyClicked diff --git a/internal/app/keymap.go b/internal/app/keymap.go index 7364896..e1489bf 100644 --- a/internal/app/keymap.go +++ b/internal/app/keymap.go @@ -5,11 +5,15 @@ import "github.com/charmbracelet/bubbles/key" type KeyMap struct { Exit key.Binding Back key.Binding - ToggleView key.Binding + Open key.Binding ToggleViewArrow key.Binding Up key.Binding + Reverse key.Binding Down key.Binding Filter key.Binding + ToggleFullHelp key.Binding + GotoTop key.Binding + GotoBottom key.Binding } var defaultKeys = KeyMap{ @@ -19,11 +23,11 @@ var defaultKeys = KeyMap{ ), Back: key.NewBinding( key.WithKeys("esc", "q"), - key.WithHelp("Esc", "Back"), + key.WithHelp("esc", "Back"), ), - ToggleView: key.NewBinding( + Open: key.NewBinding( key.WithKeys("enter"), - key.WithHelp("Enter", "Open/Hide"), + key.WithHelp("enter", "Open"), ), ToggleViewArrow: key.NewBinding( key.WithKeys("right"), @@ -32,23 +36,44 @@ var defaultKeys = KeyMap{ key.WithKeys("up"), key.WithHelp("↑", "Up"), ), + Reverse: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "Reverse"), + ), Down: key.NewBinding( key.WithKeys("down"), key.WithHelp("↓", "Down"), ), Filter: key.NewBinding( key.WithKeys("f"), - key.WithHelp("F", "Filter"), + key.WithHelp("f", "Filter"), + ), + ToggleFullHelp: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "Help"), + ), + GotoTop: key.NewBinding( + key.WithKeys("home"), + key.WithHelp("home", "go to start"), + ), + GotoBottom: key.NewBinding( + key.WithKeys("end", "G"), + key.WithHelp("end", "go to end"), ), } func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Exit, k.Back, k.ToggleView, k.Up, k.Down, k.Filter} + return []key.Binding{ + k.Back, k.Open, k.Up, k.Down, k.ToggleFullHelp, + } } func (k KeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ - {k.Back, k.Up, k.Down}, // first column - {k.ToggleView, k.Exit, k.Filter}, // second column + {k.Up, k.Down}, + {k.Back, k.Open}, + {k.Filter, k.Reverse}, + {k.GotoTop, k.GotoBottom}, + {k.ToggleFullHelp, k.Exit}, } } diff --git a/internal/app/lazytable.go b/internal/app/lazytable.go index 0056499..e5e6f84 100644 --- a/internal/app/lazytable.go +++ b/internal/app/lazytable.go @@ -1,6 +1,9 @@ package app import ( + "slices" + + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" @@ -10,56 +13,176 @@ import ( // rowGetter renders the row. type rowGetter interface { // Row return a rendered table row. - Row(cfg *config.Config) table.Row + Row(cfg *config.Config, i int) table.Row + Len() int } // lazyTableModel lazily renders table rows. -type lazyTableModel[T rowGetter] struct { - helper +type lazyTableModel struct { + *Application table table.Model - minRenderedRows int - allEntries []T - lastCursor int + entries rowGetter + lastCursor int + offset int + reverse bool + follow bool renderedRows []table.Row } -// Init implements tea.Model. -func (m lazyTableModel[T]) Init() tea.Cmd { - return nil +type EntriesUpdateMsg struct { + Entries rowGetter } // View implements tea.Model. -func (m lazyTableModel[T]) View() string { +func (m lazyTableModel) View() string { return m.table.View() } // Update implements tea.Model. -func (m lazyTableModel[T]) Update(msg tea.Msg) (lazyTableModel[T], tea.Cmd) { +func (m lazyTableModel) Update(msg tea.Msg) (lazyTableModel, tea.Cmd) { var cmd tea.Cmd + render := false + switch msg := msg.(type) { + case tea.KeyMsg: + m, render = m.handleKey(msg, render) + + case EntriesUpdateMsg: + m.entries = msg.Entries + render = true + } m.table, cmd = m.table.Update(msg) if m.table.Cursor() != m.lastCursor { - m = m.withRenderedRows() + render = true + } + + if render { + m = m.RenderedRows() } return m, cmd } -func (m lazyTableModel[T]) withRenderedRows() lazyTableModel[T] { - cursor := m.table.Cursor() +func (m lazyTableModel) handleKey(msg tea.KeyMsg, render bool) (lazyTableModel, bool) { + // toggle the reverse display of items. + if key.Matches(msg, m.Application.keys.Reverse) { + m.reverse = !m.reverse + render = true + } + + // this function increases the viewport offset by 1 if possible. (scrolls down) + increaseOffset := func() { + maxOffset := max(m.entries.Len()-m.table.Height(), 0) + o := min(m.offset+1, maxOffset) + if o != m.offset { + m.offset = o + render = true + } else { + // we were at the last item, so we should follow the log + m.follow = true + } + } - start := max(len(m.renderedRows), cursor) - end := min(cursor+m.minRenderedRows, len(m.allEntries)) + // this function decreases the viewport offset by 1 if possible. (scrolls up) + decreaseOffset := func() { + offset := max(m.offset-1, 0) + if offset != m.offset { + m.offset = offset + render = true + } else { + // we were at the first item, so we should follow the log + m.follow = true + } + } - for i := start; i < end; i++ { - m.renderedRows = append(m.renderedRows, m.allEntries[i].Row(m.Config)) + // if the table is being displayed in reverse order, we need to swap the increase and decrease functions + // since the last item is at the top of the table instead of the bottom. + if m.reverse { + increaseOffset, decreaseOffset = decreaseOffset, increaseOffset + } + + if key.Matches(msg, m.Application.keys.Down) { + m.follow = false + if m.table.Cursor()+1 == m.table.Height() { + increaseOffset() // move the viewport + } + } + if key.Matches(msg, m.Application.keys.Up) { + m.follow = false + if m.table.Cursor() == 0 { + decreaseOffset() // move the viewport + } + } + if key.Matches(msg, m.Application.keys.GotoTop) { + if m.reverse { + // when follow is enabled, rendering will handle setting the offset to the correct value + m.follow = true + } else { + m.follow = false + m.offset = 0 + } + render = true + } + if key.Matches(msg, m.Application.keys.GotoBottom) { + if m.reverse { + m.follow = false + m.offset = 0 + } else { + // when follow is enabled, rendering will handle setting the offset to the correct value + m.follow = true + } + render = true + } + + return m, render +} + +func (m lazyTableModel) ViewPortCursor() int { + if m.reverse { + viewSize := m.ViewPortEnd() - m.ViewPortStart() + + return m.offset + (viewSize - 1 - m.table.Cursor()) + } + + return m.offset + m.table.Cursor() +} + +func (m lazyTableModel) ViewPortStart() int { + return m.offset +} + +func (m lazyTableModel) ViewPortEnd() int { + return min(m.offset+m.table.Height(), m.entries.Len()) +} + +func (m lazyTableModel) RenderedRows() lazyTableModel { + if m.follow { + m.offset = max(0, m.entries.Len()-m.table.Height()) + } + end := min(m.offset+m.table.Height(), m.entries.Len()) + + m.renderedRows = []table.Row{} + for i := m.offset; i < end; i++ { + m.renderedRows = append(m.renderedRows, m.entries.Row(m.Config, i)) + } + + if m.reverse { + slices.Reverse(m.renderedRows) } m.table.SetRows(m.renderedRows) + if m.follow { + if m.reverse { + m.table.GotoTop() + } else { + m.table.GotoBottom() + } + } + m.lastCursor = m.table.Cursor() return m diff --git a/internal/app/logstable.go b/internal/app/logstable.go index affe699..6eaff73 100644 --- a/internal/app/logstable.go +++ b/internal/app/logstable.go @@ -4,21 +4,22 @@ import ( "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" + "github.com/hedhyw/json-log-viewer/internal/pkg/events" + "github.com/hedhyw/json-log-viewer/internal/pkg/source" ) type logsTableModel struct { - helper + *Application - lazyTable lazyTableModel[source.LazyLogEntry] + lazyTable lazyTableModel lastWindowSize tea.WindowSizeMsg + footerSize int logEntries source.LazyLogEntries } -func newLogsTableModel(application Application, logEntries source.LazyLogEntries) logsTableModel { - helper := helper{Application: application} - +func newLogsTableModel(application *Application, logEntries source.LazyLogEntries) logsTableModel { const cellIDLogLevel = 1 tableLogs := table.New( @@ -26,6 +27,10 @@ func newLogsTableModel(application Application, logEntries source.LazyLogEntries table.WithFocused(true), table.WithHeight(application.LastWindowSize.Height), ) + tableLogs.KeyMap.LineUp = application.keys.Up + tableLogs.KeyMap.LineDown = application.keys.Down + tableLogs.KeyMap.GotoBottom = application.keys.GotoBottom + tableLogs.KeyMap.GotoTop = application.keys.GotoTop tableLogs.SetStyles(getTableStyles()) @@ -35,7 +40,7 @@ func newLogsTableModel(application Application, logEntries source.LazyLogEntries if position.Column == cellIDLogLevel { return removeClearSequence( - helper.getLogLevelStyle( + application.getLogLevelStyle( logEntries, style, position.RowID, @@ -48,25 +53,24 @@ func newLogsTableModel(application Application, logEntries source.LazyLogEntries tableLogs.SetStyles(tableStyles) - lazyTable := lazyTableModel[source.LazyLogEntry]{ - helper: helper, - table: tableLogs, - minRenderedRows: application.Config.PrerenderRows, - allEntries: logEntries, - lastCursor: 0, - renderedRows: make([]table.Row, 0, application.Config.PrerenderRows), - }.withRenderedRows() - - return logsTableModel{ - helper: helper, - lazyTable: lazyTable, - logEntries: logEntries, + lazyTable := lazyTableModel{ + Application: application, + reverse: true, + follow: true, + table: tableLogs, + entries: logEntries, + lastCursor: 0, + renderedRows: nil, + } + + msg := logsTableModel{ + Application: application, + lazyTable: lazyTable, + logEntries: logEntries, + footerSize: 1, }.handleWindowSizeMsg(application.LastWindowSize) -} -// Init initializes component. It implements tea.Model. -func (m logsTableModel) Init() tea.Cmd { - return m.lazyTable.Init() + return msg } // View renders component. It implements tea.Model. @@ -78,10 +82,14 @@ func (m logsTableModel) View() string { func (m logsTableModel) Update(msg tea.Msg) (logsTableModel, tea.Cmd) { var cmdBatch []tea.Cmd - m.helper = m.helper.Update(msg) + m.Application.Update(msg) - if msg, ok := msg.(tea.WindowSizeMsg); ok { - m = m.handleWindowSizeMsg(msg) + switch typedMsg := msg.(type) { + case tea.WindowSizeMsg: + m = m.handleWindowSizeMsg(typedMsg) + case events.LogEntriesUpdateMsg: + m.logEntries = source.LazyLogEntries(typedMsg) + msg = EntriesUpdateMsg{Entries: m.logEntries} } m.lazyTable, cmdBatch = batched(m.lazyTable.Update(msg))(cmdBatch) @@ -97,14 +105,16 @@ func (m logsTableModel) handleWindowSizeMsg(msg tea.WindowSizeMsg) logsTableMode x, y := m.BaseStyle.GetFrameSize() m.lazyTable.table.SetWidth(msg.Width - x*2) - m.lazyTable.table.SetHeight(msg.Height - y*2 - footerSize - heightOffset) + m.lazyTable.table.SetHeight(msg.Height - y*2 - m.footerSize - heightOffset) m.lazyTable.table.SetColumns(getColumns(m.lazyTable.table.Width()+widthOffset, m.Config)) m.lastWindowSize = msg + m.lazyTable = m.lazyTable.RenderedRows() + return m } // Cursor returns the index of the selected row. func (m logsTableModel) Cursor() int { - return m.lazyTable.table.Cursor() + return m.lazyTable.ViewPortCursor() } diff --git a/internal/app/state.go b/internal/app/state.go index 642b4e0..a051df0 100644 --- a/internal/app/state.go +++ b/internal/app/state.go @@ -10,5 +10,6 @@ type stateModel interface { tea.Model fmt.Stringer - withApplication(application Application) (stateModel, tea.Cmd) + getApplication() *Application + refresh() (stateModel, tea.Cmd) } diff --git a/internal/app/stateerror.go b/internal/app/stateerror.go index 71cade3..7a72378 100644 --- a/internal/app/stateerror.go +++ b/internal/app/stateerror.go @@ -8,15 +8,15 @@ import ( // StateErrorModel is a failure message state. type StateErrorModel struct { - helper + *Application err error } -func newStateError(application Application, err error) StateErrorModel { +func newStateError(application *Application, err error) StateErrorModel { return StateErrorModel{ - helper: helper{Application: application}, - err: err, + Application: application, + err: err, } } @@ -32,7 +32,7 @@ func (s StateErrorModel) View() string { // Update handles events. It implements tea.Model. func (s StateErrorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - s.helper = s.helper.Update(msg) + s.Application.Update(msg) switch msg.(type) { case tea.KeyMsg: diff --git a/internal/app/stateerror_test.go b/internal/app/stateerror_test.go index 283f9cf..5cdfdd0 100644 --- a/internal/app/stateerror_test.go +++ b/internal/app/stateerror_test.go @@ -10,7 +10,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestStateError(t *testing.T) { @@ -18,21 +17,25 @@ func TestStateError(t *testing.T) { errTest := getTestError() - model := newTestModel(t, assets.ExampleJSONLog()) - model = handleUpdate(model, events.ErrorOccuredMsg{Err: errTest}) + setup := func() tea.Model { + model := newTestModel(t, assets.ExampleJSONLog()) + model = handleUpdate(model, events.ErrorOccuredMsg{Err: errTest}) - _, ok := model.(app.StateErrorModel) - assert.Truef(t, ok, "%s", model) + _, ok := model.(app.StateErrorModel) + assert.Truef(t, ok, "%s", model) + return model + } t.Run("rendered", func(t *testing.T) { t.Parallel() - + model := setup() rendered := model.View() assert.Contains(t, rendered, errTest.Error()) }) t.Run("any_key_msg", func(t *testing.T) { t.Parallel() + model := setup() _, cmd := model.Update(tea.KeyMsg{}) assert.Equal(t, tea.Quit(), cmd()) @@ -40,19 +43,11 @@ func TestStateError(t *testing.T) { t.Run("stringer", func(t *testing.T) { t.Parallel() + model := setup() stringer, ok := model.(fmt.Stringer) if assert.True(t, ok) { assert.Contains(t, stringer.String(), "StateError") } }) - - t.Run("unknown_update", func(t *testing.T) { - t.Parallel() - - model := handleUpdate(model, events.ViewRowsReloadRequestedMsg{}) - - _, ok := model.(app.StateErrorModel) - require.Truef(t, ok, "%s", model) - }) } diff --git a/internal/app/statefiltered.go b/internal/app/statefiltered.go index ed7ecbc..dce3c04 100644 --- a/internal/app/statefiltered.go +++ b/internal/app/statefiltered.go @@ -12,45 +12,39 @@ import ( // StateFilteredModel is a state that shows filtered records. type StateFilteredModel struct { - helper + *Application previousState StateLoadedModel table logsTableModel logEntries source.LazyLogEntries filterText string - keys KeyMap } func newStateFiltered( - application Application, previousState StateLoadedModel, filterText string, ) StateFilteredModel { return StateFilteredModel{ - helper: helper{Application: application}, + Application: previousState.Application, previousState: previousState, - table: previousState.table, filterText: filterText, - keys: defaultKeys, } } // Init initializes component. It implements tea.Model. func (s StateFilteredModel) Init() tea.Cmd { return func() tea.Msg { - return events.LogEntriesLoadedMsg( - s.previousState.logEntries.Filter(s.filterText), - ) + return &s } } // View renders component. It implements tea.Model. func (s StateFilteredModel) View() string { footer := s.Application.FooterStyle.Render( - fmt.Sprintf("filtered %d by: %s", len(s.logEntries), s.filterText), + fmt.Sprintf("filtered %d by: %s", s.logEntries.Len(), s.filterText), ) return s.BaseStyle.Render(s.table.View()) + "\n" + footer @@ -60,68 +54,69 @@ func (s StateFilteredModel) View() string { func (s StateFilteredModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmdBatch []tea.Cmd - s.helper = s.helper.Update(msg) + s.Application.Update(msg) + + switch typedMsg := msg.(type) { + case *StateFilteredModel: + entries, err := s.Application.Entries.Filter(s.filterText) + if err != nil { + return s, events.ShowError(err) + } + s.logEntries = entries + s.table = newLogsTableModel(s.Application, entries) + msg = events.LogEntriesUpdateMsg(entries) + case events.LogEntriesUpdateMsg: + entries, err := s.Application.Entries.Filter(s.filterText) + if err != nil { + return s, events.ShowError(err) + } + s.logEntries = entries + msg = events.LogEntriesUpdateMsg(entries) - switch msg := msg.(type) { case events.ErrorOccuredMsg: - return s.handleErrorOccuredMsg(msg) - case events.LogEntriesLoadedMsg: - return s.handleLogEntriesLoadedMsg(msg) + return s.handleErrorOccuredMsg(typedMsg) case events.OpenJSONRowRequestedMsg: - return s.handleOpenJSONRowRequestedMsg(msg, s) + return s.handleOpenJSONRowRequestedMsg(typedMsg, s) case tea.KeyMsg: switch { - case key.Matches(msg, s.keys.Back): - return s.previousState.withApplication(s.Application) - case key.Matches(msg, s.keys.Filter): + case key.Matches(typedMsg, s.keys.Back): + return s.previousState.refresh() + case key.Matches(typedMsg, s.keys.Filter): return s.handleFilterKeyClickedMsg() - case key.Matches(msg, s.keys.ToggleViewArrow), key.Matches(msg, s.keys.ToggleView): + case key.Matches(typedMsg, s.keys.ToggleViewArrow), key.Matches(typedMsg, s.keys.Open): return s.handleRequestOpenJSON() } - if cmd := s.handleKeyMsg(msg); cmd != nil { + if cmd := s.handleKeyMsg(typedMsg); cmd != nil { return s, cmd } default: - s.table, cmdBatch = batched(s.table.Update(msg))(cmdBatch) + s.table, cmdBatch = batched(s.table.Update(typedMsg))(cmdBatch) } s.table, cmdBatch = batched(s.table.Update(msg))(cmdBatch) - return s, tea.Batch(cmdBatch...) } -func (s StateFilteredModel) handleLogEntriesLoadedMsg( - msg events.LogEntriesLoadedMsg, -) (tea.Model, tea.Cmd) { - s.logEntries = source.LazyLogEntries(msg) - s.table = newLogsTableModel(s.Application, s.logEntries) - - return s, s.table.Init() -} - func (s StateFilteredModel) handleFilterKeyClickedMsg() (tea.Model, tea.Cmd) { - state := newStateFiltering( - s.Application, - s.previousState, - ) - + state := newStateFiltering(s.previousState) return initializeModel(state) } func (s StateFilteredModel) handleRequestOpenJSON() (tea.Model, tea.Cmd) { - if len(s.logEntries) == 0 { + if s.logEntries.Len() == 0 { return s, events.BackKeyClicked } return s, events.OpenJSONRowRequested(s.logEntries, s.table.Cursor()) } -func (s StateFilteredModel) withApplication(application Application) (stateModel, tea.Cmd) { - s.Application = application +func (s StateFilteredModel) getApplication() *Application { + return s.Application +} +func (s StateFilteredModel) refresh() (stateModel, tea.Cmd) { var cmd tea.Cmd s.table, cmd = s.table.Update(s.Application.LastWindowSize) - return s, cmd } diff --git a/internal/app/statefiltered_test.go b/internal/app/statefiltered_test.go index 8488b98..e03d312 100644 --- a/internal/app/statefiltered_test.go +++ b/internal/app/statefiltered_test.go @@ -25,47 +25,50 @@ func TestStateFiltered(t *testing.T) { {"time":"1970-01-01T00:00:00.00","level":"INFO","message": "` + termExcluded + `"} ` - model := newTestModel(t, []byte(jsonFile)) + setup := func() tea.Model { + model := newTestModel(t, []byte(jsonFile)) - rendered := model.View() - assert.Contains(t, rendered, termIncluded) - assert.Contains(t, rendered, termExcluded) + rendered := model.View() + assert.Contains(t, rendered, termIncluded) + assert.Contains(t, rendered, termExcluded) - // Open filtering. - model = handleUpdate(model, tea.KeyMsg{ - Type: tea.KeyRunes, - Runes: []rune{'f'}, - }) + // Open filtering. + model = handleUpdate(model, tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune{'f'}, + }) - lines := strings.Split(model.View(), "\n") - assert.Contains(t, lines[len(lines)-1], ">") + lines := strings.Split(model.View(), "\n") + assert.Contains(t, lines[len(lines)-1], ">") - _, ok := model.(app.StateFilteringModel) - assert.Truef(t, ok, "%s", model) + _, ok := model.(app.StateFilteringModel) + assert.Truef(t, ok, "%s", model) - // Write term to search by. - model = handleUpdate(model, tea.KeyMsg{ - Type: tea.KeyRunes, - Runes: []rune(termIncluded), - }) + // Write term to search by. + model = handleUpdate(model, tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune(termIncluded), + }) - // Filter. - model = handleUpdate(model, tea.KeyMsg{ - Type: tea.KeyEnter, - }) + // Filter. + model = handleUpdate(model, tea.KeyMsg{ + Type: tea.KeyEnter, + }) - _, ok = model.(app.StateFilteredModel) - if assert.Truef(t, ok, "%s", model) { - rendered = model.View() - assert.Contains(t, rendered, termIncluded) - assert.Contains(t, rendered, "filtered 1 by: "+termIncluded) - assert.NotContains(t, rendered, termExcluded) + _, ok = model.(app.StateFilteredModel) + if assert.Truef(t, ok, "%s", model) { + rendered = model.View() + assert.Contains(t, rendered, termIncluded) + assert.Contains(t, rendered, "filtered 1 by: "+termIncluded) + assert.NotContains(t, rendered, termExcluded) + } + return model } t.Run("reopen_filter", func(t *testing.T) { t.Parallel() - - model := handleUpdate(model, tea.KeyMsg{ + model := setup() + model = handleUpdate(model, tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune{'f'}, }) @@ -76,8 +79,8 @@ func TestStateFiltered(t *testing.T) { t.Run("open_hide_json_view", func(t *testing.T) { t.Parallel() - - model := handleUpdate(model, tea.KeyMsg{ + model := setup() + model = handleUpdate(model, tea.KeyMsg{ Type: tea.KeyEnter, }) @@ -94,8 +97,8 @@ func TestStateFiltered(t *testing.T) { t.Run("error", func(t *testing.T) { t.Parallel() - - model := handleUpdate(model, events.ErrorOccuredMsg{Err: getTestError()}) + model := setup() + model = handleUpdate(model, events.ErrorOccuredMsg{Err: getTestError()}) _, ok := model.(app.StateErrorModel) assert.Truef(t, ok, "%s", model) @@ -103,19 +106,19 @@ func TestStateFiltered(t *testing.T) { t.Run("navigation", func(t *testing.T) { t.Parallel() - - model := handleUpdate(model, tea.KeyMsg{ + model := setup() + model = handleUpdate(model, tea.KeyMsg{ Type: tea.KeyUp, }) - _, ok = model.(app.StateFilteredModel) + _, ok := model.(app.StateFilteredModel) assert.Truef(t, ok, "%s", model) }) t.Run("returned", func(t *testing.T) { t.Parallel() - - model := handleUpdate(model, tea.KeyMsg{ + model := setup() + model = handleUpdate(model, tea.KeyMsg{ Type: tea.KeyEsc, }) @@ -125,7 +128,7 @@ func TestStateFiltered(t *testing.T) { t.Run("stringer", func(t *testing.T) { t.Parallel() - + model := setup() stringer, ok := model.(fmt.Stringer) if assert.True(t, ok) { assert.Contains(t, stringer.String(), "StateFiltered") diff --git a/internal/app/statefiltering.go b/internal/app/statefiltering.go index 504ede3..19d2fa8 100644 --- a/internal/app/statefiltering.go +++ b/internal/app/statefiltering.go @@ -10,7 +10,7 @@ import ( // StateFilteringModel is a state to prompt for filter term. type StateFilteringModel struct { - helper + *Application previousState StateLoadedModel table logsTableModel @@ -20,14 +20,13 @@ type StateFilteringModel struct { } func newStateFiltering( - application Application, previousState StateLoadedModel, ) StateFilteringModel { textInput := textinput.New() textInput.Focus() return StateFilteringModel{ - helper: helper{Application: application}, + Application: previousState.Application, previousState: previousState, table: previousState.table, @@ -51,7 +50,7 @@ func (s StateFilteringModel) View() string { func (s StateFilteringModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmdBatch []tea.Cmd - s.helper = s.helper.Update(msg) + s.Application.Update(msg) switch msg := msg.(type) { case events.ErrorOccuredMsg: @@ -59,8 +58,8 @@ func (s StateFilteringModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch { case key.Matches(msg, s.keys.Back): - return s.previousState.withApplication(s.Application) - case key.Matches(msg, s.keys.ToggleView): + return s.previousState.refresh() + case key.Matches(msg, s.keys.Open): return s.handleEnterKeyClickedMsg() } if cmd := s.handleKeyMsg(msg); cmd != nil { @@ -81,7 +80,7 @@ func (s StateFilteringModel) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { return nil } - return s.helper.handleKeyMsg(msg) + return s.Application.handleKeyMsg(msg) } func (s StateFilteringModel) handleEnterKeyClickedMsg() (tea.Model, tea.Cmd) { @@ -90,7 +89,6 @@ func (s StateFilteringModel) handleEnterKeyClickedMsg() (tea.Model, tea.Cmd) { } return initializeModel(newStateFiltered( - s.Application, s.previousState, s.textInput.Value(), )) diff --git a/internal/app/statefiltering_test.go b/internal/app/statefiltering_test.go index 4f1a0ca..1094c7c 100644 --- a/internal/app/statefiltering_test.go +++ b/internal/app/statefiltering_test.go @@ -17,19 +17,22 @@ import ( func TestStateFiltering(t *testing.T) { t.Parallel() - model := newTestModel(t, assets.ExampleJSONLog()) + setup := func() tea.Model { + model := newTestModel(t, assets.ExampleJSONLog()) - model = handleUpdate(model, tea.KeyMsg{ - Type: tea.KeyRunes, - Runes: []rune{'f'}, - }) - _, ok := model.(app.StateFilteringModel) - assert.Truef(t, ok, "%s", model) + model = handleUpdate(model, tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune{'f'}, + }) + _, ok := model.(app.StateFilteringModel) + assert.Truef(t, ok, "%s", model) + return model + } t.Run("input_hotkeys", func(t *testing.T) { t.Parallel() - - model := handleUpdate(model, tea.KeyMsg{ + model := setup() + model = handleUpdate(model, tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune{'q'}, }) @@ -45,8 +48,9 @@ func TestStateFiltering(t *testing.T) { t.Run("returned", func(t *testing.T) { t.Parallel() + model := setup() - model := handleUpdate(model, tea.KeyMsg{ + model = handleUpdate(model, tea.KeyMsg{ Type: tea.KeyEsc, }) @@ -56,8 +60,9 @@ func TestStateFiltering(t *testing.T) { t.Run("empty_input", func(t *testing.T) { t.Parallel() + model := setup() - model := handleUpdate(model, tea.KeyMsg{ + model = handleUpdate(model, tea.KeyMsg{ Type: tea.KeyEnter, }) @@ -67,6 +72,7 @@ func TestStateFiltering(t *testing.T) { t.Run("stringer", func(t *testing.T) { t.Parallel() + model := setup() stringer, ok := model.(fmt.Stringer) if assert.True(t, ok) { @@ -75,18 +81,18 @@ func TestStateFiltering(t *testing.T) { }) t.Run("error", func(t *testing.T) { - t.Parallel() + model := setup() - model := handleUpdate(model, events.ErrorOccuredMsg{Err: getTestError()}) + model = handleUpdate(model, events.ErrorOccuredMsg{Err: getTestError()}) _, ok := model.(app.StateErrorModel) assert.Truef(t, ok, "%s", model) }) t.Run("navigation", func(t *testing.T) { - t.Parallel() + model := setup() - model := handleUpdate(model, tea.KeyMsg{ + model = handleUpdate(model, tea.KeyMsg{ Type: tea.KeyUp, }) @@ -104,35 +110,38 @@ func TestStateFilteringReset(t *testing.T) { {"time":"1970-01-01T00:00:00.00","level":"INFO","message": "` + termIncluded + `"} ` - model := newTestModel(t, []byte(jsonFile)) + setup := func() tea.Model { + model := newTestModel(t, []byte(jsonFile)) - rendered := model.View() - assert.Contains(t, rendered, termIncluded) + rendered := model.View() + assert.Contains(t, rendered, termIncluded) - // Open filter. - model = handleUpdate(model, tea.KeyMsg{ - Type: tea.KeyRunes, - Runes: []rune{'f'}, - }) + // Open filter. + model = handleUpdate(model, tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune{'f'}, + }) - _, ok := model.(app.StateFilteringModel) - assert.Truef(t, ok, "%s", model) + _, ok := model.(app.StateFilteringModel) + assert.Truef(t, ok, "%s", model) - // Filter to exclude everything. - model = handleUpdate(model, tea.KeyMsg{ - Type: tea.KeyRunes, - Runes: []rune(termIncluded + "_not_found"), - }) - model = handleUpdate(model, tea.KeyMsg{ - Type: tea.KeyEnter, - }) + // Filter to exclude everything. + model = handleUpdate(model, tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune(termIncluded + "_not_found"), + }) + model = handleUpdate(model, tea.KeyMsg{ + Type: tea.KeyEnter, + }) - _, ok = model.(app.StateFilteredModel) - assert.Truef(t, ok, "%s", model) + _, ok = model.(app.StateFilteredModel) + assert.Truef(t, ok, "%s", model) + return model + } t.Run("record_not_included", func(t *testing.T) { t.Parallel() - + model := setup() rendered := model.View() index := strings.Index(rendered, "filtered 0 by:") @@ -143,11 +152,11 @@ func TestStateFilteringReset(t *testing.T) { assert.NotContains(t, rendered, termIncluded) // Come back - model := handleUpdate(model, tea.KeyMsg{ + model = handleUpdate(model, tea.KeyMsg{ Type: tea.KeyEsc, }) - _, ok = model.(app.StateLoadedModel) + _, ok := model.(app.StateLoadedModel) assert.Truef(t, ok, "%s", model) // Assert. @@ -157,9 +166,10 @@ func TestStateFilteringReset(t *testing.T) { t.Run("record_not_included", func(t *testing.T) { t.Parallel() + model := setup() // Try to open a record where there are no records. - model := handleUpdate(model, tea.KeyMsg{ + model = handleUpdate(model, tea.KeyMsg{ Type: tea.KeyEnter, }) diff --git a/internal/app/stateinitial.go b/internal/app/stateinitial.go index 7b7d133..95cea40 100644 --- a/internal/app/stateinitial.go +++ b/internal/app/stateinitial.go @@ -1,8 +1,6 @@ package app import ( - "time" - tea "github.com/charmbracelet/bubbletea" "github.com/hedhyw/json-log-viewer/internal/pkg/events" @@ -10,18 +8,18 @@ import ( // StateInitialModel is an initial loading state. type StateInitialModel struct { - helper + *Application } -func newStateInitial(application Application) StateInitialModel { +func newStateInitial(application *Application) StateInitialModel { return StateInitialModel{ - helper: helper{Application: application}, + Application: application, } } // Init initializes component. It implements tea.Model. func (s StateInitialModel) Init() tea.Cmd { - return s.helper.LoadEntries + return nil } // View renders component. It implements tea.Model. @@ -31,13 +29,13 @@ func (s StateInitialModel) View() string { // Update handles events. It implements tea.Model. func (s StateInitialModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - s.helper = s.helper.Update(msg) + s.Application.Update(msg) switch msg := msg.(type) { case events.ErrorOccuredMsg: return s.handleErrorOccuredMsg(msg) - case events.LogEntriesLoadedMsg: - return s.handleLogEntriesLoadedMsg(msg, time.UnixMilli(0)) + case events.LogEntriesUpdateMsg: + return s.handleInitialLogEntriesLoadedMsg(msg) case tea.KeyMsg: return s, tea.Quit default: diff --git a/internal/app/stateinitial_test.go b/internal/app/stateinitial_test.go index e197e4d..b96815c 100644 --- a/internal/app/stateinitial_test.go +++ b/internal/app/stateinitial_test.go @@ -4,12 +4,11 @@ import ( "bytes" "fmt" "testing" - "time" "github.com/hedhyw/json-log-viewer/internal/app" "github.com/hedhyw/json-log-viewer/internal/pkg/config" "github.com/hedhyw/json-log-viewer/internal/pkg/events" - "github.com/hedhyw/json-log-viewer/internal/pkg/source/readerinput" + "github.com/hedhyw/json-log-viewer/internal/pkg/source" tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" @@ -19,12 +18,20 @@ import ( func TestStateInitial(t *testing.T) { t.Parallel() + is, err := source.Reader(bytes.NewReader([]byte{}), config.GetDefaultConfig()) + require.NoError(t, err) + t.Cleanup(func() { _ = is.Close() }) + model := app.NewModel( - readerinput.New(bytes.NewReader([]byte{}), time.Millisecond), + "-", config.GetDefaultConfig(), testVersion, ) + entries, err := is.ParseLogEntries() + require.NoError(t, err) + handleUpdate(model, events.LogEntriesUpdateMsg(entries)) + _, ok := model.(app.StateInitialModel) require.Truef(t, ok, "%s", model) @@ -55,13 +62,4 @@ func TestStateInitial(t *testing.T) { assert.Equal(t, tea.Quit(), cmd()) }) - - t.Run("unknown_update", func(t *testing.T) { - t.Parallel() - - model := handleUpdate(model, events.ViewRowsReloadRequestedMsg{}) - - _, ok := model.(app.StateInitialModel) - require.Truef(t, ok, "%s", model) - }) } diff --git a/internal/app/stateloaded.go b/internal/app/stateloaded.go index c30f46c..9a9b7ee 100644 --- a/internal/app/stateloaded.go +++ b/internal/app/stateloaded.go @@ -1,11 +1,12 @@ package app import ( - "time" + "fmt" + "strings" - "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/hedhyw/json-log-viewer/internal/pkg/events" "github.com/hedhyw/json-log-viewer/internal/pkg/source" @@ -13,52 +14,31 @@ import ( // StateLoadedModel is a state that shows all loaded records. type StateLoadedModel struct { - helper + *Application - initCmd tea.Cmd - - table logsTableModel - logEntries source.LazyLogEntries - lastReloadAt time.Time - - keys KeyMap - help help.Model - reloading bool + table logsTableModel } func newStateViewLogs( - application Application, + application *Application, logEntries source.LazyLogEntries, - lastReloadAt time.Time, ) StateLoadedModel { table := newLogsTableModel(application, logEntries) return StateLoadedModel{ - helper: helper{Application: application}, - - initCmd: table.Init(), - - table: table, - logEntries: logEntries, - - keys: defaultKeys, - help: help.New(), + Application: application, - lastReloadAt: lastReloadAt, + table: table, } } // Init initializes component. It implements tea.Model. func (s StateLoadedModel) Init() tea.Cmd { - return s.initCmd + return nil } // View renders component. It implements tea.Model. func (s StateLoadedModel) View() string { - if s.reloading { - return s.viewTable() + "\nreloading..." - } - return s.viewTable() + s.viewHelp() } @@ -67,7 +47,47 @@ func (s StateLoadedModel) viewTable() string { } func (s StateLoadedModel) viewHelp() string { - return "\n" + s.Version + " " + s.help.View(s.keys) + toggles := func() string { + toggles := []string{} + if s.table.lazyTable.reverse { + toggles = append(toggles, "reverse") + } + if s.table.lazyTable.follow { + toggles = append(toggles, "following") + } + if len(toggles) > 0 { + return fmt.Sprintf("( %s )", strings.Join(toggles, ", ")) + } + return "" + } + + if s.help.ShowAll { + toggleText := lipgloss.NewStyle(). + Background(lipgloss.Color("#353533")). + Padding(0, 1). + Render(toggles()) + + versionText := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFDF5")). + Background(lipgloss.Color("#6124DF")). + Padding(0, 1). + Render(s.Version) + + width := s.Application.LastWindowSize.Width + fillerText := lipgloss.NewStyle(). + Background(lipgloss.Color("#353533")). + Width(width - lipgloss.Width(toggleText) - lipgloss.Width(versionText)). + Render("") + + bar := lipgloss.JoinHorizontal(lipgloss.Top, + toggleText, + fillerText, + versionText, + ) + + return "\n" + s.help.View(s.keys) + "\n" + lipgloss.NewStyle().Width(width).Render(bar) + } + return "\n" + s.help.View(s.keys) + " " + toggles() } // Update handles events. It implements tea.Model. @@ -76,29 +96,29 @@ func (s StateLoadedModel) viewHelp() string { func (s StateLoadedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmdBatch []tea.Cmd - s.helper = s.helper.Update(msg) + s.Application.Update(msg) switch msg := msg.(type) { case events.ErrorOccuredMsg: return s.handleErrorOccuredMsg(msg) - case events.LogEntriesLoadedMsg: - return s.handleLogEntriesLoadedMsg(msg, s.lastReloadAt) - case events.ViewRowsReloadRequestedMsg: - return s.handleViewRowsReloadRequestedMsg() case events.OpenJSONRowRequestedMsg: return s.handleOpenJSONRowRequestedMsg(msg, s) case tea.KeyMsg: - if s.reloading { - return s, nil - } - switch { case key.Matches(msg, s.keys.Back): return s, tea.Quit case key.Matches(msg, s.keys.Filter): return s.handleFilterKeyClickedMsg() - case key.Matches(msg, s.keys.ToggleViewArrow), key.Matches(msg, s.keys.ToggleView): + case key.Matches(msg, s.keys.ToggleViewArrow), key.Matches(msg, s.keys.Open): return s.handleRequestOpenJSON() + case key.Matches(msg, s.keys.ToggleFullHelp): + s.help.ShowAll = !s.help.ShowAll + if s.help.ShowAll { + s.table.footerSize = 3 + } else { + s.table.footerSize = 1 + } + return s.refresh() } cmdBatch = append(cmdBatch, s.handleKeyMsg(msg)...) } @@ -111,52 +131,26 @@ func (s StateLoadedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (s StateLoadedModel) handleKeyMsg(msg tea.KeyMsg) []tea.Cmd { var cmdBatch []tea.Cmd - cmdBatch = appendCmd(cmdBatch, s.helper.handleKeyMsg(msg)) - - if key.Matches(msg, s.keys.Up) { - cmdBatch = appendCmd(cmdBatch, s.handleArrowUpKeyClicked()) - } + cmdBatch = appendCmd(cmdBatch, s.Application.handleKeyMsg(msg)) return cmdBatch } -func (s StateLoadedModel) handleArrowUpKeyClicked() tea.Cmd { - if s.table.Cursor() == 0 { - return events.ViewRowsReloadRequested - } - - return nil -} - func (s StateLoadedModel) handleRequestOpenJSON() (tea.Model, tea.Cmd) { - if len(s.logEntries) == 0 { - return s, tea.Quit - } - - return s, events.OpenJSONRowRequested(s.logEntries, s.table.Cursor()) -} - -func (s StateLoadedModel) handleViewRowsReloadRequestedMsg() (tea.Model, tea.Cmd) { - if time.Since(s.lastReloadAt) < s.Config.ReloadThreshold || s.reloading { - return s, nil - } - - s.lastReloadAt = time.Now() - s.reloading = true - - return s, s.helper.LoadEntries + return s, events.OpenJSONRowRequested(s.Entries, s.table.Cursor()) } func (s StateLoadedModel) handleFilterKeyClickedMsg() (tea.Model, tea.Cmd) { - return initializeModel(newStateFiltering(s.helper.Application, s)) + return initializeModel(newStateFiltering(s)) } -func (s StateLoadedModel) withApplication(application Application) (stateModel, tea.Cmd) { - s.helper.Application = application +func (s StateLoadedModel) getApplication() *Application { + return s.Application +} +func (s StateLoadedModel) refresh() (stateModel, tea.Cmd) { var cmd tea.Cmd - s.table, cmd = s.table.Update(s.helper.Application.LastWindowSize) - + s.table, cmd = s.table.Update(s.Application.LastWindowSize) return s, cmd } @@ -164,7 +158,3 @@ func (s StateLoadedModel) withApplication(application Application) (stateModel, func (s StateLoadedModel) String() string { return modelValue(s) } - -func (s StateLoadedModel) Application() Application { - return s.helper.Application -} diff --git a/internal/app/stateloaded_test.go b/internal/app/stateloaded_test.go index 1dd3ef9..5b75c93 100644 --- a/internal/app/stateloaded_test.go +++ b/internal/app/stateloaded_test.go @@ -2,19 +2,18 @@ package app_test import ( "fmt" - "os" "strings" "testing" + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/hedhyw/json-log-viewer/assets" "github.com/hedhyw/json-log-viewer/internal/app" "github.com/hedhyw/json-log-viewer/internal/pkg/config" "github.com/hedhyw/json-log-viewer/internal/pkg/events" "github.com/hedhyw/json-log-viewer/internal/pkg/source" - - tea "github.com/charmbracelet/bubbletea" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestStateLoadedEmpty(t *testing.T) { @@ -25,7 +24,7 @@ func TestStateLoadedEmpty(t *testing.T) { _, ok := model.(app.StateLoadedModel) require.Truef(t, ok, "%s", model) - model, cmd := model.Update(events.EnterKeyClicked()) + model, cmd := model.Update(events.EscKeyClicked()) require.NotNil(t, model) requireCmdMsg(t, tea.Quit(), cmd) } @@ -33,15 +32,20 @@ func TestStateLoadedEmpty(t *testing.T) { func TestStateLoaded(t *testing.T) { t.Parallel() - const jsonFile = `{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "test"}` + setup := func() tea.Model { + const jsonFile = `{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "test"}` - model := newTestModel(t, []byte(jsonFile)) + model := newTestModel(t, []byte(jsonFile)) - _, ok := model.(app.StateLoadedModel) - require.Truef(t, ok, "%s", model) + _, ok := model.(app.StateLoadedModel) + require.Truef(t, ok, "%s", model) + + return model + } t.Run("stringer", func(t *testing.T) { t.Parallel() + model := setup() stringer, ok := model.(fmt.Stringer) if assert.True(t, ok) { @@ -51,27 +55,30 @@ func TestStateLoaded(t *testing.T) { t.Run("error", func(t *testing.T) { t.Parallel() + model := setup() - model := handleUpdate(model, events.ErrorOccuredMsg{Err: getTestError()}) + model = handleUpdate(model, events.ErrorOccuredMsg{Err: getTestError()}) - _, ok = model.(app.StateErrorModel) + _, ok := model.(app.StateErrorModel) assert.Truef(t, ok, "%s", model) }) t.Run("version_printed", func(t *testing.T) { t.Parallel() + model := setup() - assert.Contains(t, model.View(), testVersion) + model = handleUpdate(model, events.HelpKeyClicked()) + view := model.View() + assert.Contains(t, view, testVersion) }) } func TestStateLoadedQuit(t *testing.T) { t.Parallel() - model := newTestModel(t, assets.ExampleJSONLog()) - t.Run("ctrl_and_c", func(t *testing.T) { t.Parallel() + model := newTestModel(t, assets.ExampleJSONLog()) _, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) requireCmdMsg(t, tea.Quit(), cmd) @@ -79,6 +86,7 @@ func TestStateLoadedQuit(t *testing.T) { t.Run("esc", func(t *testing.T) { t.Parallel() + model := newTestModel(t, assets.ExampleJSONLog()) _, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) requireCmdMsg(t, tea.Quit(), cmd) @@ -86,6 +94,7 @@ func TestStateLoadedQuit(t *testing.T) { t.Run("q", func(t *testing.T) { t.Parallel() + model := newTestModel(t, assets.ExampleJSONLog()) _, cmd := model.Update(tea.KeyMsg{ Type: tea.KeyRunes, @@ -96,6 +105,7 @@ func TestStateLoadedQuit(t *testing.T) { t.Run("f10", func(t *testing.T) { t.Parallel() + model := newTestModel(t, assets.ExampleJSONLog()) _, cmd := model.Update(tea.KeyMsg{ Type: tea.KeyF10, @@ -104,81 +114,6 @@ func TestStateLoadedQuit(t *testing.T) { }) } -func TestStateLoadedReload(t *testing.T) { - t.Parallel() - - const expected = "included" - - const ( - jsonFile = ` - {"time":"1970-01-01T00:00:00.00","level":"INFO","message": "test2"} - {"time":"1970-01-01T00:00:00.00","level":"INFO","message": "test1"} - ` - - jsonFileUpdated = ` - {"time":"1970-01-01T00:00:00.00","level":"INFO","message": "` + expected + `"} - ` + jsonFile - ) - - model := newTestModel(t, []byte(jsonFile)) - - rendered := model.View() - assert.NotContains(t, rendered, expected) - - overwriteFileInStateLoaded(t, model, []byte(jsonFileUpdated)) - - t.Run("up", func(t *testing.T) { - t.Parallel() - - model := handleUpdate(model, tea.KeyMsg{ - Type: tea.KeyUp, - }) - - rendered := model.View() - assert.Contains(t, rendered, expected) - }) - - t.Run("up_down_up_up", func(t *testing.T) { - t.Parallel() - - // Go from the first row to the second and back. - model := handleUpdate(model, tea.KeyMsg{ - Type: tea.KeyDown, - }) - model = handleUpdate(model, tea.KeyMsg{ - Type: tea.KeyUp, - }) - assert.NotContains(t, rendered, expected) - - // Press Up, there are no rows. - model = handleUpdate(model, tea.KeyMsg{ - Type: tea.KeyUp, - }) - - rendered := model.View() - assert.Contains(t, rendered, expected) - }) - - t.Run("threshold", func(t *testing.T) { - t.Parallel() - - model := newTestModel(t, []byte(jsonFile)) - - model = handleUpdate(model, tea.KeyMsg{ - Type: tea.KeyUp, - }) - - overwriteFileInStateLoaded(t, model, []byte(jsonFileUpdated)) - - model = handleUpdate(model, tea.KeyMsg{ - Type: tea.KeyUp, - }) - - rendered := model.View() - assert.NotContains(t, rendered, expected) - }) -} - /* go test -benchmem -run=^$ -bench ^BenchmarkStateLoadedBig$ github.com/hedhyw/json-log-viewer/internal/app @@ -203,25 +138,14 @@ func BenchmarkStateLoadedBig(b *testing.B) { b.ResetTimer() - logEntries, err := source.ParseLogEntriesFromReader(contentReader, cfg) + is, err := source.Reader(contentReader, cfg) + require.NoError(b, err) + b.Cleanup(func() { _ = is.Close() }) + + logEntries, err := is.ParseLogEntries() if err != nil { b.Fatal(model.View()) } - model.Update(events.LogEntriesLoadedMsg(logEntries)) -} - -func overwriteFileInStateLoaded(tb testing.TB, model tea.Model, content []byte) { - tb.Helper() - - stateLoaded, ok := model.(app.StateLoadedModel) - require.True(tb, ok) - - // nolint: gosec // Test. - err := os.WriteFile( - stateLoaded.Application().SourceInput.String(), - content, - os.ModePerm, - ) - require.NoError(tb, err) + model.Update(events.LogEntriesUpdateMsg(logEntries)) } diff --git a/internal/app/stateviewrow.go b/internal/app/stateviewrow.go index d7d6e10..c909aec 100644 --- a/internal/app/stateviewrow.go +++ b/internal/app/stateviewrow.go @@ -11,7 +11,7 @@ import ( // StateViewRowModel is a state that shows extended JSON view. type StateViewRowModel struct { - helper + *Application previousState stateModel initCmd tea.Cmd @@ -23,14 +23,13 @@ type StateViewRowModel struct { } func newStateViewRow( - application Application, logEntry source.LogEntry, previousState stateModel, ) StateViewRowModel { - jsonViewModel, cmd := widgets.NewJSONViewModel(logEntry.Line, application.LastWindowSize) + jsonViewModel, cmd := widgets.NewJSONViewModel(logEntry.Line, previousState.getApplication().LastWindowSize) return StateViewRowModel{ - helper: helper{Application: application}, + Application: previousState.getApplication(), previousState: previousState, initCmd: cmd, @@ -56,18 +55,14 @@ func (s StateViewRowModel) View() string { func (s StateViewRowModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd - s.helper = s.helper.Update(msg) + s.Application.Update(msg) switch msg := msg.(type) { case events.ErrorOccuredMsg: return s.handleErrorOccuredMsg(msg) case tea.KeyMsg: - if key.Matches(msg, s.keys.Back) || key.Matches(msg, s.keys.ToggleView) { - return s.previousState.withApplication(s.Application) - } - - if cmd = s.handleKeyMsg(msg); cmd != nil { - return s, cmd + if key.Matches(msg, s.keys.Back) { + return s.previousState.refresh() } } @@ -76,14 +71,6 @@ func (s StateViewRowModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, cmd } -func (s StateViewRowModel) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { - if key.Matches(msg, s.keys.ToggleViewArrow) { - return nil - } - - return s.helper.handleKeyMsg(msg) -} - // String implements fmt.Stringer. func (s StateViewRowModel) String() string { return modelValue(s) diff --git a/internal/app/stateviewrow_test.go b/internal/app/stateviewrow_test.go index 954df85..20f5093 100644 --- a/internal/app/stateviewrow_test.go +++ b/internal/app/stateviewrow_test.go @@ -14,25 +14,26 @@ import ( ) func TestStateViewRow(t *testing.T) { - t.Parallel() - - model := newTestModel(t, assets.ExampleJSONLog()) - - model = handleUpdate(model, tea.KeyMsg{Type: tea.KeyEnter}) + setup := func(t *testing.T) tea.Model { + t.Parallel() + model := newTestModel(t, assets.ExampleJSONLog()) + model = handleUpdate(model, tea.KeyMsg{Type: tea.KeyEnter}) + _, ok := model.(app.StateViewRowModel) - _, ok := model.(app.StateViewRowModel) - require.Truef(t, ok, "%s", model) + require.Truef(t, ok, "%s", model) + return model + } t.Run("close", func(t *testing.T) { - t.Parallel() + model := setup(t) - model := handleUpdate(model, tea.KeyMsg{Type: tea.KeyEnter}) + model = handleUpdate(model, tea.KeyMsg{Type: tea.KeyEsc}) _, ok := model.(app.StateLoadedModel) require.Truef(t, ok, "%s", model) }) t.Run("stringer", func(t *testing.T) { - t.Parallel() + model := setup(t) stringer, ok := model.(fmt.Stringer) if assert.True(t, ok) { @@ -41,9 +42,8 @@ func TestStateViewRow(t *testing.T) { }) t.Run("error", func(t *testing.T) { - t.Parallel() - - model := handleUpdate(model, events.ErrorOccuredMsg{Err: getTestError()}) + model := setup(t) + model = handleUpdate(model, events.ErrorOccuredMsg{Err: getTestError()}) _, ok := model.(app.StateErrorModel) assert.Truef(t, ok, "%s", model) @@ -51,6 +51,7 @@ func TestStateViewRow(t *testing.T) { // nolint: tparallel // antonmedv/fx uses mutable model. t.Run("navigation", func(t *testing.T) { + model := setup(t) model = handleUpdate(model, tea.KeyMsg{ Type: tea.KeyRight, }) diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 22af276..6feed59 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "os" - "time" "github.com/go-playground/validator/v10" "github.com/hedhyw/jsoncjson" @@ -23,15 +22,8 @@ type Config struct { CustomLevelMapping map[string]string `json:"customLevelMapping"` - // The number of rows to prerender. - PrerenderRows int `json:"prerenderRows"` - // ReloadThreshold is the minimum duration between reloading rows. - ReloadThreshold time.Duration `json:"reloadThreshold" validate:"min=100ms"` // MaxFileSizeBytes is the maximum size of the file to load. MaxFileSizeBytes int64 `json:"maxFileSizeBytes" validate:"min=1"` - - // StdinReadTimeout is the timeout of reading from the standart input. - StdinReadTimeout time.Duration `json:"stdinReadTimeout" validate:"min=100ms"` } // FieldKind describes the type of the log field. @@ -63,10 +55,7 @@ func GetDefaultConfig() *Config { return &Config{ Path: "default", CustomLevelMapping: GetDefaultCustomLevelMapping(), - PrerenderRows: 100, - ReloadThreshold: time.Second, MaxFileSizeBytes: 1024 * 1024 * 1024, - StdinReadTimeout: time.Second, Fields: []Field{{ Title: "Time", Kind: FieldKindNumericTime, diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index ee8f799..38461dd 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -30,26 +30,6 @@ func TestReadDefault(t *testing.T) { } } -func TestReadPartlyDefault(t *testing.T) { - t.Parallel() - - const reloadThreshold = time.Minute + time.Second - - configDefault := config.GetDefaultConfig() - configJSON := tests.RequireEncodeJSON(t, map[string]any{ - "reloadThreshold": reloadThreshold, - }) - configFile := tests.RequireCreateFile(t, configJSON) - - assert.NotEqual(t, reloadThreshold, configDefault.ReloadThreshold) - - cfg, err := config.Read(configFile) - if assert.NoError(t, err) { - assert.Equal(t, reloadThreshold, cfg.ReloadThreshold) - assert.Equal(t, configDefault.StdinReadTimeout, cfg.StdinReadTimeout) - } -} - func TestReadNotFound(t *testing.T) { t.Parallel() @@ -111,7 +91,7 @@ func ExampleGetDefaultConfig() { var buf bytes.Buffer jsonEncoder := json.NewEncoder(&buf) - jsonEncoder.SetIndent("", "\t") + jsonEncoder.SetIndent("", " ") if err := jsonEncoder.Encode(&cfg); err != nil { log.Fatal(err) @@ -120,52 +100,49 @@ func ExampleGetDefaultConfig() { fmt.Println(buf.String()) // Output: // { - // "fields": [ - // { - // "title": "Time", - // "kind": "numerictime", - // "ref": [ - // "$.timestamp", - // "$.time", - // "$.t", - // "$.ts" - // ], - // "width": 30 - // }, - // { - // "title": "Level", - // "kind": "level", - // "ref": [ - // "$.level", - // "$.lvl", - // "$.l" - // ], - // "width": 10 - // }, - // { - // "title": "Message", - // "kind": "message", - // "ref": [ - // "$.message", - // "$.msg", - // "$.error", - // "$.err" - // ], - // "width": 0 - // } - // ], - // "customLevelMapping": { - // "10": "trace", - // "20": "debug", - // "30": "info", - // "40": "warn", - // "50": "error", - // "60": "fatal" - // }, - // "prerenderRows": 100, - // "reloadThreshold": 1000000000, - // "maxFileSizeBytes": 1073741824, - // "stdinReadTimeout": 1000000000 + // "fields": [ + // { + // "title": "Time", + // "kind": "numerictime", + // "ref": [ + // "$.timestamp", + // "$.time", + // "$.t", + // "$.ts" + // ], + // "width": 30 + // }, + // { + // "title": "Level", + // "kind": "level", + // "ref": [ + // "$.level", + // "$.lvl", + // "$.l" + // ], + // "width": 10 + // }, + // { + // "title": "Message", + // "kind": "message", + // "ref": [ + // "$.message", + // "$.msg", + // "$.error", + // "$.err" + // ], + // "width": 0 + // } + // ], + // "customLevelMapping": { + // "10": "trace", + // "20": "debug", + // "30": "info", + // "40": "warn", + // "50": "error", + // "60": "fatal" + // }, + // "maxFileSizeBytes": 1073741824 // } } diff --git a/internal/pkg/events/events.go b/internal/pkg/events/events.go index 5a94119..dcc8b27 100644 --- a/internal/pkg/events/events.go +++ b/internal/pkg/events/events.go @@ -7,8 +7,9 @@ import ( ) type ( - // LogEntriesLoadedMsg is an event about successfully loaded log entries. - LogEntriesLoadedMsg source.LazyLogEntries + // LogEntriesUpdateMsg is an event about successfully updated log entries. + LogEntriesUpdateMsg source.LazyLogEntries + LogEntriesEOF struct{} // ErrorOccuredMsg is a generic error event. ErrorOccuredMsg struct{ Err error } @@ -22,9 +23,6 @@ type ( // Index of the row. Index int } - - // ViewRowsReloadRequestedMsg is an event to start reloading of logs. - ViewRowsReloadRequestedMsg struct{} ) // OpenJSONRowRequested implements tea.Cmd. It creates OpenJSONRowRequestedMsg. @@ -37,9 +35,21 @@ func OpenJSONRowRequested(logEntries source.LazyLogEntries, index int) func() te } } -// ViewRowsReloadRequested implements tea.Cmd. It creates ViewRowsReloadRequestedMsg. -func ViewRowsReloadRequested() tea.Msg { - return ViewRowsReloadRequestedMsg{} +func ShowError(err error) func() tea.Msg { + return func() tea.Msg { + return ErrorOccuredMsg{Err: err} + } +} + +func HelpKeyClicked() tea.Msg { + return tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune{'?'}, + } +} + +func EscKeyClicked() tea.Msg { + return tea.KeyMsg{Type: tea.KeyEsc} } // EnterKeyClicked implements tea.Cmd. It creates a message indicating 'Enter' has been clicked. diff --git a/internal/pkg/source/entry.go b/internal/pkg/source/entry.go index 4497b2c..3cdee61 100644 --- a/internal/pkg/source/entry.go +++ b/internal/pkg/source/entry.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "os" "strconv" "strings" "time" @@ -17,23 +18,41 @@ import ( // LazyLogEntry holds unredenred LogEntry. Use `LogEntry` getter. type LazyLogEntry struct { - Line json.RawMessage + offset int64 + length int } -// LogEntry parses and returns `LogEntry`. -func (e LazyLogEntry) LogEntry(cfg *config.Config) LogEntry { - return ParseLogEntry(e.Line, cfg) +func (e LazyLogEntry) Length() int { + return e.length } -// Row returns table.Row representation of the log entry. -func (e LazyLogEntry) Row(cfg *config.Config) table.Row { - return e.LogEntry(cfg).Fields +func (e LazyLogEntry) Line(file *os.File) (json.RawMessage, error) { + data := make([]byte, e.length) + _, err := file.ReadAt(data, e.offset) + if err != nil { + return nil, err + } + + return data, nil +} + +// LogEntry parses and returns `LogEntry`. +func (e LazyLogEntry) LogEntry(file *os.File, cfg *config.Config) LogEntry { + line, err := e.Line(file) + if err != nil { + return LogEntry{ + Error: err, + } + } + + return ParseLogEntry(line, cfg) } // LogEntry is a single partly-parse record of the log. type LogEntry struct { Fields []string Line json.RawMessage + Error error } // Row returns table.Row representation of the log entry. @@ -42,34 +61,44 @@ func (e LogEntry) Row() table.Row { } // LazyLogEntries is a helper type definition for the slice of lazy log entries. -type LazyLogEntries []LazyLogEntry +type LazyLogEntries struct { + Seeker *os.File + Entries []LazyLogEntry +} + +// Row returns table.Row representation of the log entry. +func (entries LazyLogEntries) Row(cfg *config.Config, i int) table.Row { + return entries.Entries[i].LogEntry(entries.Seeker, cfg).Fields +} + +func (entries LazyLogEntries) Len() int { + return len(entries.Entries) +} // Filter filters entries by ignore case exact match. -func (entries LazyLogEntries) Filter(term string) LazyLogEntries { +func (entries LazyLogEntries) Filter(term string) (LazyLogEntries, error) { if term == "" { - return entries + return entries, nil } termLower := bytes.ToLower([]byte(term)) - filtered := make(LazyLogEntries, 0, len(entries)) + filtered := make([]LazyLogEntry, 0, len(entries.Entries)) - for _, f := range entries { - if bytes.Contains(bytes.ToLower(f.Line), termLower) { + for _, f := range entries.Entries { + line, err := f.Line(entries.Seeker) + if err != nil { + return LazyLogEntries{}, err + } + if bytes.Contains(bytes.ToLower(line), termLower) { filtered = append(filtered, f) } } - return filtered -} - -// Reverse all entries. -func (entries LazyLogEntries) Reverse() LazyLogEntries { - for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 { - entries[i], entries[j] = entries[j], entries[i] - } - - return entries + return LazyLogEntries{ + Seeker: entries.Seeker, + Entries: filtered, + }, nil } func parseField( diff --git a/internal/pkg/source/entry_test.go b/internal/pkg/source/entry_test.go index 34b4904..b3a6401 100644 --- a/internal/pkg/source/entry_test.go +++ b/internal/pkg/source/entry_test.go @@ -1,11 +1,15 @@ package source_test import ( + "bytes" "encoding/json" + "fmt" "strings" "testing" "time" + "github.com/stretchr/testify/require" + "github.com/hedhyw/json-log-viewer/internal/pkg/config" "github.com/hedhyw/json-log-viewer/internal/pkg/source" @@ -209,52 +213,6 @@ func TestLogEntryRow(t *testing.T) { assert.Equal(t, []string(row), entry.Fields) } -func TestLazyLogEntriesReverse(t *testing.T) { - t.Parallel() - - t.Run("simple", func(t *testing.T) { - t.Parallel() - - original := source.LazyLogEntries{ - getFakeLazyLogEntry(), - getFakeLazyLogEntry(), - getFakeLazyLogEntry(), - } - - entries := make(source.LazyLogEntries, len(original)) - copy(entries, original) - actual := entries.Reverse() - - assert.Equal(t, actual[0], original[2]) - assert.Equal(t, actual[1], original[1]) - assert.Equal(t, actual[2], original[0]) - }) - - t.Run("single", func(t *testing.T) { - t.Parallel() - - entries := source.LazyLogEntries{ - getFakeLazyLogEntry(), - } - - assert.Len(t, entries, 1) - }) - - t.Run("empty", func(t *testing.T) { - t.Parallel() - - entries := source.LazyLogEntries{} - - assert.Empty(t, entries) - }) -} - -func getFakeLazyLogEntry() source.LazyLogEntry { - return source.LazyLogEntry{ - Line: getFakeLogEntry().Line, - } -} - func getFakeLogEntry() source.LogEntry { return source.LogEntry{ Fields: []string{ @@ -271,44 +229,68 @@ func TestLazyLogEntriesFilter(t *testing.T) { term := "special MESSAGE to search by in the test: " + t.Name() - logEntry := getFakeLazyLogEntry() - logEntry.Line = json.RawMessage(`{"message": "` + term + `"}`) + logs := fmt.Sprintf(` +{"hello":"world"} +{"message", "%s"} +{"hello":"world"} +`, term) + + createEntries := func() (*source.Source, source.LazyLogEntries, source.LazyLogEntry) { + is, err := source.Reader(bytes.NewReader([]byte(logs)), config.GetDefaultConfig()) + require.NoError(t, err) + + logEntries, err := is.ParseLogEntries() + require.NoError(t, err) + + logEntry := logEntries.Entries[1] - logEntries := source.LazyLogEntries{ - getFakeLazyLogEntry(), - logEntry, - getFakeLazyLogEntry(), + return is, logEntries, logEntry } t.Run("all", func(t *testing.T) { t.Parallel() + is, logEntries, _ := createEntries() + defer is.Close() - assert.Len(t, logEntries.Filter(""), len(logEntries)) + assert.Len(t, logEntries.Entries, logEntries.Len()) }) t.Run("found_exact", func(t *testing.T) { t.Parallel() - filtered := logEntries.Filter(term) - if assert.Len(t, filtered, 1) { - assert.Equal(t, logEntry, filtered[0]) + is, logEntries, logEntry := createEntries() + defer is.Close() + + filtered, err := logEntries.Filter(term) + require.NoError(t, err) + + if assert.Len(t, filtered.Entries, 1) { + assert.Equal(t, logEntry, filtered.Entries[0]) } }) t.Run("found_ignore_case", func(t *testing.T) { t.Parallel() + is, logEntries, logEntry := createEntries() + defer is.Close() - filtered := logEntries.Filter(strings.ToUpper(term)) - if assert.Len(t, filtered, 1) { - assert.Equal(t, logEntry, filtered[0]) + filtered, err := logEntries.Filter(strings.ToUpper(term)) + require.NoError(t, err) + + if assert.Len(t, filtered.Entries, 1) { + assert.Equal(t, logEntry, filtered.Entries[0]) } }) t.Run("not_found", func(t *testing.T) { t.Parallel() + is, logEntries, _ := createEntries() + defer is.Close() + + filtered, err := logEntries.Filter(term + " - not found!") + require.NoError(t, err) - filtered := logEntries.Filter(term + " - not found!") - assert.Empty(t, filtered) + assert.Empty(t, filtered.Entries) }) } diff --git a/internal/pkg/source/fileinput/fileinput.go b/internal/pkg/source/fileinput/fileinput.go deleted file mode 100644 index 645a164..0000000 --- a/internal/pkg/source/fileinput/fileinput.go +++ /dev/null @@ -1,29 +0,0 @@ -package fileinput - -import ( - "context" - "io" - "os" -) - -// FileInput is the source that represents the file. -type FileInput struct { - fileName string -} - -// New initializes a new FileInput with the given file. -func New(fileName string) FileInput { - return FileInput{ - fileName: fileName, - } -} - -// ReadCloser opens the file. Call Close after usage. -func (s FileInput) ReadCloser(context.Context) (io.ReadCloser, error) { - return os.Open(s.fileName) -} - -// String implements fmt.Stringer. -func (s FileInput) String() string { - return s.fileName -} diff --git a/internal/pkg/source/fileinput/fileinput_test.go b/internal/pkg/source/fileinput/fileinput_test.go deleted file mode 100644 index 5a0dee4..0000000 --- a/internal/pkg/source/fileinput/fileinput_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package fileinput_test - -import ( - "context" - "io" - "testing" - - "github.com/hedhyw/json-log-viewer/assets" - "github.com/hedhyw/json-log-viewer/internal/pkg/source/fileinput" - "github.com/hedhyw/json-log-viewer/internal/pkg/tests" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFileInputString(t *testing.T) { - t.Parallel() - - ctx := context.Background() - - expected := assets.ExampleJSONLog() - testFile := tests.RequireCreateFile(t, expected) - - t.Run("ReadCloser", func(t *testing.T) { - t.Parallel() - - input := fileinput.New(testFile) - - readCloser, err := input.ReadCloser(ctx) - require.NoError(t, err) - - t.Cleanup(func() { assert.NoError(t, readCloser.Close()) }) - - actual, err := io.ReadAll(readCloser) - require.NoError(t, err) - - assert.Equal(t, expected, actual) - }) - - t.Run("String", func(t *testing.T) { - t.Parallel() - - input := fileinput.New(testFile) - - assert.Equal(t, testFile, input.String()) - }) - - t.Run("NotFound", func(t *testing.T) { - t.Parallel() - - input := fileinput.New("not_found_for_" + t.Name()) - - _, err := input.ReadCloser(ctx) - require.Error(t, err) - }) -} diff --git a/internal/pkg/source/input.go b/internal/pkg/source/input.go deleted file mode 100644 index e0f1506..0000000 --- a/internal/pkg/source/input.go +++ /dev/null @@ -1,15 +0,0 @@ -package source - -import ( - "context" - "fmt" - "io" -) - -// Input returns the getter of read-closer for the given input source. -type Input interface { - // ReadCloser returns a reader from the input. Call `Close` after usage. - ReadCloser(ctx context.Context) (io.ReadCloser, error) - - fmt.Stringer -} diff --git a/internal/pkg/source/readerinput/readerinput.go b/internal/pkg/source/readerinput/readerinput.go deleted file mode 100644 index 7c08756..0000000 --- a/internal/pkg/source/readerinput/readerinput.go +++ /dev/null @@ -1,90 +0,0 @@ -package readerinput - -import ( - "bufio" - "bytes" - "context" - "io" - "time" -) - -// ReaderInput reads from the configured input with some timeout. -type ReaderInput struct { - readTimeout time.Duration - - linesChan <-chan []byte - errChan <-chan error - - lastErr error - content []byte -} - -// New initializes ReaderInput with the given reader and timeout. -func New( - reader io.Reader, - timeout time.Duration, -) *ReaderInput { - scanner := bufio.NewScanner(reader) - - linesChan := make(chan []byte) - errChan := make(chan error, 1) - - go func() { - for scanner.Scan() { - linesChan <- scanner.Bytes() - } - - if err := scanner.Err(); err != nil { - errChan <- err - close(errChan) - } else { - close(linesChan) - } - }() - - return &ReaderInput{ - readTimeout: timeout, - - linesChan: linesChan, - errChan: errChan, - - lastErr: nil, - content: make([]byte, 0), - } -} - -// String implements fmt.Stringer. -func (s *ReaderInput) String() string { - return "-" -} - -// ReadCloser reads the content from the input. -func (s *ReaderInput) ReadCloser(ctx context.Context) (io.ReadCloser, error) { - if s.lastErr != nil { - return nil, s.lastErr - } - - ctx, cancel := context.WithTimeout(ctx, s.readTimeout) - defer cancel() - -loop: - for ctx.Err() == nil { - select { - case line, ok := <-s.linesChan: - if !ok { - break loop - } - - s.content = append(s.content, line...) - s.content = append(s.content, "\n"...) - case err := <-s.errChan: - s.lastErr = err - - return nil, s.lastErr - case <-ctx.Done(): - break loop - } - } - - return io.NopCloser(bytes.NewReader(s.content)), nil -} diff --git a/internal/pkg/source/readerinput/readerinput_test.go b/internal/pkg/source/readerinput/readerinput_test.go deleted file mode 100644 index fe73be2..0000000 --- a/internal/pkg/source/readerinput/readerinput_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package readerinput_test - -import ( - "bytes" - "context" - "errors" - "io" - "testing" - "testing/iotest" - "time" - - "github.com/hedhyw/json-log-viewer/assets" - "github.com/hedhyw/json-log-viewer/internal/pkg/source/readerinput" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestReaderInput(t *testing.T) { - t.Parallel() - - ctx := context.Background() - expected := assets.ExampleJSONLog() - - t.Run("ReadCloser", func(t *testing.T) { - t.Parallel() - - input := readerinput.New(bytes.NewReader(expected), time.Minute) - - readCloser, err := input.ReadCloser(ctx) - require.NoError(t, err) - - t.Cleanup(func() { assert.NoError(t, readCloser.Close()) }) - - actual, err := io.ReadAll(readCloser) - require.NoError(t, err) - - assert.Equal(t, bytes.TrimSpace(expected), bytes.TrimSpace(actual)) - }) - - t.Run("ReadCloser_twice", func(t *testing.T) { - t.Parallel() - - input := readerinput.New(bytes.NewReader(expected), time.Minute) - - for range 2 { - readCloser, err := input.ReadCloser(ctx) - require.NoError(t, err) - - t.Cleanup(func() { assert.NoError(t, readCloser.Close()) }) - - actual, err := io.ReadAll(readCloser) - require.NoError(t, err) - - assert.Equal(t, bytes.TrimSpace(expected), bytes.TrimSpace(actual)) - } - }) - - t.Run("ReadCloser_error", func(t *testing.T) { - t.Parallel() - - // nolint: err113 // Test error. - errReader := errors.New(t.Name()) - - input := readerinput.New(iotest.ErrReader(errReader), time.Minute) - - _, err := input.ReadCloser(ctx) - require.Error(t, err) - require.ErrorIs(t, err, errReader) - - _, err = input.ReadCloser(ctx) - require.Error(t, err) - require.ErrorIs(t, err, errReader) - }) - - t.Run("ReadCloser_wait", func(t *testing.T) { - t.Parallel() - - const ( - lineFirst = "line first\n" - lineSecond = "line second\n" - - timeout = 200 * time.Millisecond - ) - - pipeReader, pipeWriter := io.Pipe() - - t.Cleanup(func() { assert.NoError(t, pipeReader.Close()) }) - t.Cleanup(func() { assert.NoError(t, pipeWriter.Close()) }) - - input := readerinput.New(pipeReader, timeout) - - _, err := pipeWriter.Write([]byte(lineFirst)) - require.NoError(t, err) - - readCloser, err := input.ReadCloser(ctx) - require.NoError(t, err) - - actual, err := io.ReadAll(readCloser) - require.NoError(t, err) - - assert.Equal(t, lineFirst, string(actual)) - - _, err = pipeWriter.Write([]byte(lineSecond)) - require.NoError(t, err) - - readCloser, err = input.ReadCloser(ctx) - require.NoError(t, err) - - actual, err = io.ReadAll(readCloser) - require.NoError(t, err) - - assert.Equal(t, lineFirst+lineSecond, string(actual)) - }) - - t.Run("String", func(t *testing.T) { - t.Parallel() - - input := readerinput.New(bytes.NewReader(nil), time.Minute) - - assert.Equal(t, "-", input.String()) - }) - - t.Run("empty", func(t *testing.T) { - t.Parallel() - - input := readerinput.New(bytes.NewReader(nil), time.Minute) - - readCloser, err := input.ReadCloser(ctx) - require.NoError(t, err) - - t.Cleanup(func() { assert.NoError(t, readCloser.Close()) }) - - actual, err := io.ReadAll(readCloser) - require.NoError(t, err) - - assert.Empty(t, actual) - }) -} diff --git a/internal/pkg/source/source.go b/internal/pkg/source/source.go index 09a5116..8358642 100644 --- a/internal/pkg/source/source.go +++ b/internal/pkg/source/source.go @@ -4,47 +4,164 @@ import ( "bufio" "bytes" "errors" - "fmt" "io" + "os" + + "github.com/hedhyw/semerr/pkg/v1/semerr" "github.com/hedhyw/json-log-viewer/internal/pkg/config" ) const ( maxLineSize = 8 * 1024 * 1024 - - logEntriesEstimateNumber = 256 ) -// ParseLogEntriesFromReader reads the input and parses all logs. -func ParseLogEntriesFromReader( - reader io.Reader, - cfg *config.Config, -) (LazyLogEntries, error) { - reader = io.LimitReader(reader, cfg.MaxFileSizeBytes) +type Source struct { + // Seeker is used to do random access reads from the file. + Seeker *os.File + // Reader is used to read the file sequentially. + reader *bufio.Reader + // The log file we are reading from, or a temp file we are writing to (depending on if created with File or Reader func). + file *os.File + // offset is the next offset a long entry will be read from. + offset int64 + // prevFollowSize is the size of the file the last time we checked + prevFollowSize int64 + // name is the name of the file we are reading. + name string + // maxSize is the maximum size of the file we will read. + maxSize int64 +} + +func (is *Source) Close() (err error) { + err = is.file.Close() + e := is.Seeker.Close() + if e != nil { + err = e + } + return err +} + +// File creates a new Source for reading log entries from a file. +func File(name string, cfg *config.Config) (*Source, error) { + var err error + is := &Source{ + maxSize: cfg.MaxFileSizeBytes, + name: name, + } + + is.file, err = os.Open(name) + if err != nil { + return nil, err + } + + is.Seeker, err = os.Open(name) + if err != nil { + _ = is.file.Close() + return nil, err + } + + is.reader = bufio.NewReaderSize(io.LimitReader(is.file, is.maxSize), maxLineSize) + return is, nil +} + +// Reader creates a new Source for reading log entries from an io.Reader. This will write the input to a temp file. +// which will be used to seek against. +func Reader(input io.Reader, cfg *config.Config) (*Source, error) { + var err error + is := &Source{ + maxSize: cfg.MaxFileSizeBytes, + } + + // We will write the as read to a temp file. Seek against the temp file. + is.file, err = os.CreateTemp("", "jvl-*.log") + if err != nil { + return nil, err + } - bufReader := bufio.NewReaderSize(reader, maxLineSize) - logEntries := make(LazyLogEntries, 0, logEntriesEstimateNumber) + // The io.TeeReader will write the input to the is.file as it is read. + reader := io.TeeReader(input, is.file) + + // We can now seek against the data that is read in the input io.Reader. + is.Seeker, err = os.Open(is.file.Name()) + if err != nil { + _ = is.file.Close() + return nil, err + } + reader = io.LimitReader(reader, is.maxSize) + is.reader = bufio.NewReaderSize(reader, maxLineSize) + return is, nil +} + +func (is *Source) ParseLogEntries() (LazyLogEntries, error) { + logEntries := make([]LazyLogEntry, 0, 1000) for { - line, _, err := bufReader.ReadLine() + entry, err := is.ReadLogEntry() if err != nil { if errors.Is(err, io.EOF) { break } - return nil, fmt.Errorf("reading line: %w", err) + return LazyLogEntries{}, err } + logEntries = append(logEntries, entry) + } + + return LazyLogEntries{ + Seeker: is.Seeker, + Entries: logEntries, + }, nil +} - line = bytes.TrimSpace(line) +func (is *Source) CanFollow() bool { + return len(is.name) != 0 +} - if len(line) > 0 { - lineClone := make([]byte, len(line)) - copy(lineClone, line) +const ErrFileTruncated semerr.Error = "file truncated" - logEntries = append(logEntries, LazyLogEntry{Line: lineClone}) +// ReadLogEntry reads the next ReadLogEntry from the file. +func (is *Source) ReadLogEntry() (LazyLogEntry, error) { + for { + if is.reader == nil { + // If we can't follow the file, or we have reached the max size, we are done. + if !is.CanFollow() || is.offset >= is.maxSize { + return LazyLogEntry{}, io.EOF + } + + // has the file size changed since we last looked? + info, err := os.Stat(is.name) + if err != nil || is.prevFollowSize == info.Size() { + return LazyLogEntry{}, io.EOF + } + + if info.Size() < is.offset { + // the file has been truncated or rolled over, all previous line offsets are invalid. + // we can't recover from this. + return LazyLogEntry{}, ErrFileTruncated + } + is.prevFollowSize = info.Size() + // reset the reader and try to read the file again. + _, _ = is.file.Seek(is.offset, io.SeekStart) + is.reader = bufio.NewReaderSize(io.LimitReader(is.file, is.maxSize-is.offset), maxLineSize) } - } - return logEntries.Reverse(), nil + line, err := is.reader.ReadSlice(byte('\n')) + if err != nil { + if errors.Is(err, io.EOF) { + // set the reader to nil so that we can recover from EOF. + is.reader = nil + } + return LazyLogEntry{}, err + } + length := len(line) + offset := is.offset + is.offset += int64(length) + if len(bytes.TrimSpace(line)) != 0 { + return LazyLogEntry{ + offset: offset, + length: length, + }, nil + } + } } diff --git a/internal/pkg/source/source_test.go b/internal/pkg/source/source_test.go index b70b001..345968c 100644 --- a/internal/pkg/source/source_test.go +++ b/internal/pkg/source/source_test.go @@ -19,10 +19,12 @@ func TestLoadLogsFromFile(t *testing.T) { t.Run("ok", func(t *testing.T) { t.Parallel() - logEntries, err := source.ParseLogEntriesFromReader( - bytes.NewReader(assets.ExampleJSONLog()), - config.GetDefaultConfig(), - ) + reader := bytes.NewReader(assets.ExampleJSONLog()) + is, err := source.Reader(reader, config.GetDefaultConfig()) + require.NoError(t, err) + defer is.Close() + logEntries, err := is.ParseLogEntries() + require.NoError(t, err) assert.NotEmpty(t, logEntries) }) @@ -32,10 +34,12 @@ func TestLoadLogsFromFile(t *testing.T) { longLine := strings.Repeat("1", 2*1024*1024) - logEntries, err := source.ParseLogEntriesFromReader( - strings.NewReader(longLine), - config.GetDefaultConfig(), - ) + reader := strings.NewReader(longLine) + is, err := source.Reader(reader, config.GetDefaultConfig()) + require.NoError(t, err) + defer is.Close() + logEntries, err := is.ParseLogEntries() + require.NoError(t, err) assert.NotEmpty(t, logEntries) }) @@ -49,10 +53,12 @@ func TestParseLogEntriesFromReaderLimited(t *testing.T) { cfg := config.GetDefaultConfig() cfg.MaxFileSizeBytes = 1 - logEntries, err := source.ParseLogEntriesFromReader(strings.NewReader(content), cfg) + reader := strings.NewReader(content) + is, err := source.Reader(reader, cfg) + require.NoError(t, err) + defer is.Close() + logEntries, err := is.ParseLogEntries() require.NoError(t, err) - if assert.Len(t, logEntries, 1) { - assert.Equal(t, content[:cfg.MaxFileSizeBytes], string(logEntries[0].Line)) - } + require.Empty(t, logEntries.Entries) } diff --git a/internal/pkg/source/sreamer.go b/internal/pkg/source/sreamer.go new file mode 100644 index 0000000..69cb2c3 --- /dev/null +++ b/internal/pkg/source/sreamer.go @@ -0,0 +1,99 @@ +package source + +import ( + "context" + "errors" + "io" + "sync" + "time" +) + +const InitialLogSize int = 1000 + +func (is *Source) StartStreaming(ctx context.Context, send func(msg LazyLogEntries, err error)) { + logEntriesLock := sync.Mutex{} + logEntries := make([]LazyLogEntry, 0, InitialLogSize) + eofEvent := make(chan struct{}, 1) + + // Load log entries async.. + go is.readLogEntries(ctx, send, &logEntriesLock, &logEntries, eofEvent) + + // periodically send new log entries to the program. + go func() { + ticker := time.NewTicker(200 * time.Millisecond) + lastLen := -1 + defer ticker.Stop() + + sendUpdates := func() { + // Only send log update the program state every ticker seconds, + // to avoid stressing the main loop. + logEntriesLock.Lock() + entries := logEntries + logEntriesLock.Unlock() + + nextLen := len(entries) + if lastLen != nextLen { + send(LazyLogEntries{ + Seeker: is.Seeker, + Entries: entries, + }, nil) + lastLen = nextLen + } + } + + for { + select { + case <-ctx.Done(): + + return + case <-eofEvent: + sendUpdates() + + return + case <-ticker.C: + sendUpdates() + } + } + }() +} + +func (is *Source) readLogEntries(ctx context.Context, send func(msg LazyLogEntries, err error), logEntriesLock *sync.Mutex, logEntries *[]LazyLogEntry, eofEvent chan struct{}) { + defer func() { + eofEvent <- struct{}{} + }() + + for { + select { + case <-ctx.Done(): + return + default: + } + + entry, err := is.ReadLogEntry() + if err != nil { + if errors.Is(err, io.EOF) { + if !is.CanFollow() { + return + } + + // wait for new log entries to be written to the file, + // and try again. + ticker := time.NewTicker(200 * time.Millisecond) + select { + case <-ctx.Done(): + ticker.Stop() + return + case <-ticker.C: + ticker.Stop() + } + + continue + } + send(LazyLogEntries{}, err) + return + } + logEntriesLock.Lock() + *logEntries = append(*logEntries, entry) + logEntriesLock.Unlock() + } +}