Repository: hedhyw/json-log-viewer (go)

Issue:
feat: support hot-reload
After clicking arrow-up if it is the first record and results were not filtered then reload logs.

Pull request description:
feat: support hot-reload
Closes #15

Patch:
diff --git a/Makefile b/Makefile
index 9620a89..a084c33 100644
--- a/Makefile
+++ b/Makefile
@@ -3,6 +3,8 @@ OUT_BIN?=${PWD}/bin/jlv
 COVER_PACKAGES=./...
 VERSION?=${shell git describe --tags}
 
+all: lint test build
+
 run:
 	@echo "building ${VERSION}"
 	go run ./cmd/jlv assets/example.log
diff --git a/README.md b/README.md
index 8fc4b66..4438530 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,8 @@ jlv file.json
 | Esc    | Back           |
 | ↑↓     | Navigation     |
 
+> \[↑\] Click Up on the first row to reload the file.
+
 ## Install
 
 ### MacOS/Linux HomeBrew
diff --git a/internal/app/handler.go b/internal/app/handler.go
index ef95ee6..47edd3c 100644
--- a/internal/app/handler.go
+++ b/internal/app/handler.go
@@ -9,6 +9,12 @@ import (
 const cellIDLogLevel = 1
 
 func (m Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+	for _, key := range m.table.KeyMap.LineUp.Keys() {
+		if msg.String() == key {
+			return m.handleUp()
+		}
+	}
+
 	switch msg.String() {
 	case "esc":
 		return m.back()
@@ -26,6 +32,10 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
 }
 
 func (m Model) handleEnter() (tea.Model, tea.Cmd) {
+	if m.IsErrorShown() {
+		return m.quit()
+	}
+
 	if m.IsFilterShown() {
 		return m.applyFilter()
 	}
@@ -37,6 +47,16 @@ func (m Model) handleEnter() (tea.Model, tea.Cmd) {
 	return nil, nil
 }
 
+func (m Model) handleUp() (tea.Model, tea.Cmd) {
+	if m.table.Cursor() != 0 || !m.IsTableShown() || m.IsFiltered() {
+		return nil, nil
+	}
+
+	m.allLogEntries = nil
+
+	return m, m.Init()
+}
+
 func (m Model) handleWindowSizeMsg(msg tea.WindowSizeMsg) Model {
 	x, y := m.baseStyle.GetFrameSize()
 	m.table.SetWidth(msg.Width - x*2)
@@ -69,6 +89,7 @@ func (m Model) handleLogEntriesMsg(msg source.LogEntries) Model {
 
 	m.table.SetRows(msg.Rows())
 	m.filteredLogEntries = msg
+	m.table.UpdateViewport()
 
 	return m
 }
diff --git a/internal/app/property.go b/internal/app/property.go
index b4cbdba..63cdc18 100644
--- a/internal/app/property.go
+++ b/internal/app/property.go
@@ -24,3 +24,8 @@ func (m Model) IsFilterShown() bool {
 func (m Model) IsFiltered() bool {
 	return len(m.allLogEntries) != len(m.filteredLogEntries)
 }
+
+// File returns the path to the current file.
+func (m Model) File() string {
+	return m.fileLogPath
+}
diff --git a/internal/app/view.go b/internal/app/view.go
index 615e6f2..4d32fb1 100644
--- a/internal/app/view.go
+++ b/internal/app/view.go
@@ -18,7 +18,7 @@ func (m Model) renderViews() string {
 	}
 
 	if m.IsErrorShown() {
-		return fmt.Sprintf("something went wrong: %s", m.err)
+		return fmt.Sprintf("Something went wrong: %s.", m.err)
 	}
 
 	var footer string
diff --git a/internal/pkg/source/source.go b/internal/pkg/source/source.go
index 94aa286..0792b0a 100644
--- a/internal/pkg/source/source.go
+++ b/internal/pkg/source/source.go
@@ -14,7 +14,7 @@ func LoadLogsFromFile(path string) func() tea.Msg {
 	return func() (msg tea.Msg) {
 		file, err := os.Open(path)
 		if err != nil {
-			return fmt.Errorf("opening: %w", err)
+			return fmt.Errorf("os: %w", err)
 		}
 
 		defer file.Close()


Test patch:
diff --git a/internal/app/app_test.go b/internal/app/app_test.go
index d019895..91824d1 100644
--- a/internal/app/app_test.go
+++ b/internal/app/app_test.go
@@ -1,10 +1,14 @@
 package app_test
 
 import (
+	"os"
+	"strings"
 	"testing"
+	"unicode/utf8"
 
 	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"
@@ -119,26 +123,9 @@ func TestAppQuit(t *testing.T) {
 	})
 }
 
-func newTestModel(tb testing.TB, content []byte) app.Model {
-	tb.Helper()
-
-	testFile := tests.RequireCreateFile(tb, content)
-
-	appModel := app.NewModel(testFile)
-	cmd := appModel.Init()
-
-	appModel, _ = toAppModel(appModel.Update(cmd()))
-
-	return appModel
-}
-
-func toAppModel(teaModel tea.Model, cmd tea.Cmd) (app.Model, tea.Cmd) {
-	appModel, _ := teaModel.(app.Model)
-
-	return appModel, cmd
-}
-
 func TestAppViewFiltereRunes(t *testing.T) {
+	t.Parallel()
+
 	appModel := newTestModel(t, assets.ExampleJSONLog())
 
 	appModel, _ = toAppModel(appModel.Update(tea.KeyMsg{
@@ -160,3 +147,73 @@ func TestAppViewFiltereRunes(t *testing.T) {
 	assert.NotEqual(t, tea.Quit(), cmd())
 	assert.True(t, appModel.IsFilterShown(), appModel.View())
 }
+
+func TestAppViewReload(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
+	)
+
+	appModel := newTestModel(t, []byte(jsonFile))
+
+	rendered := appModel.View()
+	assert.NotContains(t, rendered, expected)
+
+	err := os.WriteFile(appModel.File(), []byte(jsonFileUpdated), os.ModePerm)
+	require.NoError(t, err)
+
+	appModel, _ = toAppModel(appModel.Update(tea.KeyMsg{
+		Type: tea.KeyUp,
+	}))
+
+	rendered = appModel.View()
+	assert.NotContains(t, rendered, expected)
+}
+
+func TestAppViewResized(t *testing.T) {
+	t.Parallel()
+
+	appModel := newTestModel(t, assets.ExampleJSONLog())
+
+	windowSize := tea.WindowSizeMsg{
+		Width:  60,
+		Height: 10,
+	}
+
+	appModel, _ = toAppModel(appModel.Update(windowSize))
+
+	rendered := appModel.View()
+	lines := strings.Split(rendered, "\n")
+	if assert.NotEmpty(t, lines, rendered) {
+		assert.Less(t, utf8.RuneCountInString(lines[0]), windowSize.Width, rendered)
+	}
+}
+
+func newTestModel(tb testing.TB, content []byte) app.Model {
+	tb.Helper()
+
+	testFile := tests.RequireCreateFile(tb, content)
+
+	appModel := app.NewModel(testFile)
+	cmd := appModel.Init()
+
+	appModel, _ = toAppModel(appModel.Update(cmd()))
+
+	return appModel
+}
+
+func toAppModel(teaModel tea.Model, cmd tea.Cmd) (app.Model, tea.Cmd) {
+	appModel, _ := teaModel.(app.Model)
+
+	return appModel, cmd
+}