From f5ba9a874e2cf470c3f1e348e264280d6d98e5c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B5=D0=B3=20=D0=A2=D0=B0=D0=BB=D0=B0=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D0=B2?= Date: Wed, 17 Dec 2025 09:21:59 +0300 Subject: [PATCH 1/3] feat: Vim motions navigation Added minimal usage of `j` and `k` for navigation across the logs --- docs/usage.md | 2 +- internal/keymap/keymap.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index c2497f3..d751e1e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -10,7 +10,7 @@ | R | Reverse | | Ctrl+C | Exit | | F10 | Exit | -| ↑↓ | Line Up / Down | +| ↑↓ / jk| Line Up / Down | | PgUp | Page Up | | PgDown | Page Down | | Home | Navigate to Start | diff --git a/internal/keymap/keymap.go b/internal/keymap/keymap.go index 8905bbd..e5094ad 100644 --- a/internal/keymap/keymap.go +++ b/internal/keymap/keymap.go @@ -40,16 +40,16 @@ func GetDefaultKeys() KeyMap { key.WithKeys("right"), ), Up: key.NewBinding( - key.WithKeys("up"), - key.WithHelp("↑", "Up"), + key.WithKeys("up", "k"), + key.WithHelp("(↑, k)", "Up"), ), Reverse: key.NewBinding( key.WithKeys("r"), key.WithHelp("r", "Reverse"), ), Down: key.NewBinding( - key.WithKeys("down"), - key.WithHelp("↓", "Down"), + key.WithKeys("down", "j"), + key.WithHelp("(↓, j)", "Down"), ), PageUp: key.NewBinding( key.WithKeys("pgup"), From 7282aafc63c207c0ee087dd7569dc812b95126e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B5=D0=B3=20=D0=A2=D0=B0=D0=BB=D0=B0=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D0=B2?= Date: Wed, 17 Dec 2025 20:28:03 +0300 Subject: [PATCH 2/3] feat/regex-search-support --- docs/usage.md | 1 + internal/app/stateloaded.go | 6 ++ internal/app/stateregexfiltered.go | 135 ++++++++++++++++++++++++++++ internal/app/stateregexfiltering.go | 103 +++++++++++++++++++++ internal/keymap/keymap.go | 6 ++ internal/pkg/source/entry.go | 31 +++++++ 6 files changed, 282 insertions(+) create mode 100644 internal/app/stateregexfiltered.go create mode 100644 internal/app/stateregexfiltering.go diff --git a/docs/usage.md b/docs/usage.md index 170843a..6ea7329 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -7,6 +7,7 @@ | Enter | Open log | | Esc | Back | | F | Filter | +| / | RegExp Filter | | R | Reverse | | Ctrl+C | Exit | | F10 | Exit | diff --git a/internal/app/stateloaded.go b/internal/app/stateloaded.go index 5e2ee99..d8432df 100644 --- a/internal/app/stateloaded.go +++ b/internal/app/stateloaded.go @@ -118,6 +118,8 @@ func (s StateLoadedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, tea.Quit case key.Matches(msg, s.keys.Filter): return s.handleFilterKeyClickedMsg() + case key.Matches(msg, s.keys.FilterRegex): + return s.handleRegexFilterKeyClickedMsg() case key.Matches(msg, s.keys.ToggleViewArrow), key.Matches(msg, s.keys.Open): return s.handleRequestOpenJSON() case key.Matches(msg, s.keys.ToggleFullHelp): @@ -153,6 +155,10 @@ func (s StateLoadedModel) handleFilterKeyClickedMsg() (tea.Model, tea.Cmd) { return initializeModel(newStateFiltering(s)) } +func (s StateLoadedModel) handleRegexFilterKeyClickedMsg() (tea.Model, tea.Cmd) { + return initializeModel(newStateRegexFiltering(s)) +} + func (s StateLoadedModel) getApplication() *Application { return s.Application } diff --git a/internal/app/stateregexfiltered.go b/internal/app/stateregexfiltered.go new file mode 100644 index 0000000..7a97644 --- /dev/null +++ b/internal/app/stateregexfiltered.go @@ -0,0 +1,135 @@ +package app + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + + "github.com/hedhyw/json-log-viewer/internal/pkg/events" + "github.com/hedhyw/json-log-viewer/internal/pkg/source" +) + +// StateRegexFilteredModel is a state that shows regex-filtered records. +type StateRegexFilteredModel struct { + *Application + + previousState StateLoadedModel + table logsTableModel + logEntries source.LazyLogEntries + + regexPattern string +} + +func newStateRegexFiltered( + previousState StateLoadedModel, + regexPattern string, +) StateRegexFilteredModel { + return StateRegexFilteredModel{ + Application: previousState.Application, + + previousState: previousState, + + regexPattern: regexPattern, + } +} + +func (s StateRegexFilteredModel) Init() tea.Cmd { + return func() tea.Msg { + return &s + } +} + +func (s StateRegexFilteredModel) View() string { + footer := s.FooterStyle.Render( + fmt.Sprintf("regex filtered %d by: /%s/", s.logEntries.Len(), s.regexPattern), + ) + + return s.BaseStyle.Render(s.table.View()) + "\n" + footer +} + +func (s StateRegexFilteredModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmdBatch []tea.Cmd + + s.Application.Update(msg) + + if _, ok := msg.(*StateRegexFilteredModel); ok { + s, msg = s.handleStateRegexFilteredModel() + } + + if _, ok := msg.(events.LogEntriesUpdateMsg); ok { + return s, nil + } + + switch typedMsg := msg.(type) { + case events.ErrorOccuredMsg: + return s.handleErrorOccuredMsg(typedMsg) + case events.OpenJSONRowRequestedMsg: + return s.handleOpenJSONRowRequestedMsg(typedMsg, s) + case tea.KeyMsg: + if mdl, cmd := s.handleKeyMsg(typedMsg); mdl != nil { + return mdl, cmd + } + } + + s.table, cmdBatch = batched(s.table.Update(msg))(cmdBatch) + + return s, tea.Batch(cmdBatch...) +} + +func (s StateRegexFilteredModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, s.keys.Back): + return s.previousState.refresh() + case key.Matches(msg, s.keys.FilterRegex): + return s.handleRegexFilterKeyClickedMsg() + case key.Matches(msg, s.keys.ToggleViewArrow), key.Matches(msg, s.keys.Open): + return s.handleRequestOpenJSON() + default: + return nil, nil + } +} + +func (s StateRegexFilteredModel) handleStateRegexFilteredModel() (StateRegexFilteredModel, tea.Msg) { + entries, err := s.Application.Entries().FilterRegExp(s.regexPattern) + if err != nil { + return s, events.ShowError(err)() + } + + s.logEntries = entries + s.table = newLogsTableModel( + s.Application, + entries, + false, // follow. + s.previousState.table.lazyTable.reverse, + ) + + return s, nil +} + +func (s StateRegexFilteredModel) handleRegexFilterKeyClickedMsg() (tea.Model, tea.Cmd) { + state := newStateRegexFiltering(s.previousState) + return initializeModel(state) +} + +func (s StateRegexFilteredModel) handleRequestOpenJSON() (tea.Model, tea.Cmd) { + if s.logEntries.Len() == 0 { + return s, events.EscKeyClicked + } + + return s, events.OpenJSONRowRequested(s.logEntries, s.table.Cursor()) +} + +func (s StateRegexFilteredModel) getApplication() *Application { + return s.Application +} + +func (s StateRegexFilteredModel) refresh() (_ stateModel, cmd tea.Cmd) { + s.table, cmd = s.table.Update(s.LastWindowSize()) + + return s, cmd +} + +func (s StateRegexFilteredModel) String() string { + return modelValue(s) +} diff --git a/internal/app/stateregexfiltering.go b/internal/app/stateregexfiltering.go new file mode 100644 index 0000000..99b3e3a --- /dev/null +++ b/internal/app/stateregexfiltering.go @@ -0,0 +1,103 @@ +package app + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/hedhyw/json-log-viewer/internal/keymap" + "github.com/hedhyw/json-log-viewer/internal/pkg/events" +) + +// StateRegexFilteringModel is a state to prompt for regex filter pattern. +type StateRegexFilteringModel struct { + *Application + + previousState StateLoadedModel + table logsTableModel + + textInput textinput.Model + keys keymap.KeyMap +} + +func newStateRegexFiltering( + previousState StateLoadedModel, +) StateRegexFilteringModel { + textInput := textinput.New() + textInput.Focus() + textInput.Placeholder = "Enter regex pattern..." + + return StateRegexFilteringModel{ + Application: previousState.Application, + + previousState: previousState, + table: previousState.table, + + textInput: textInput, + keys: previousState.getApplication().keys, + } +} + +func (s StateRegexFilteringModel) Init() tea.Cmd { + return nil +} + +func (s StateRegexFilteringModel) View() string { + return s.BaseStyle.Render(s.table.View()) + "\n" + + "Regex filter: " + s.textInput.View() +} + +func (s StateRegexFilteringModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmdBatch []tea.Cmd + + s.Application.Update(msg) + + switch msg := msg.(type) { + case events.ErrorOccuredMsg: + return s.handleErrorOccuredMsg(msg) + case tea.KeyMsg: + if mdl, cmd := s.handleKeyMsg(msg); mdl != nil { + return mdl, cmd + } + default: + s.table, cmdBatch = batched(s.table.Update(msg))(cmdBatch) + } + + s.textInput, cmdBatch = batched(s.textInput.Update(msg))(cmdBatch) + + return s, tea.Batch(cmdBatch...) +} + +func (s StateRegexFilteringModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, s.keys.Back) && string(msg.Runes) != "q": + return s.previousState.refresh() + case key.Matches(msg, s.keys.Open): + return s.handleEnterKeyClickedMsg() + default: + return nil, nil + } +} + +func (s StateRegexFilteringModel) handleEnterKeyClickedMsg() (tea.Model, tea.Cmd) { + if s.textInput.Value() == "" { + return s, events.EscKeyClicked + } + + return initializeModel(newStateRegexFiltered( + s.previousState, + s.textInput.Value(), + )) +} + +func (s StateRegexFilteringModel) getApplication() *Application { + return s.Application +} + +func (s StateRegexFilteringModel) refresh() (stateModel, tea.Cmd) { + return s, nil +} + +func (s StateRegexFilteringModel) String() string { + return modelValue(s) +} diff --git a/internal/keymap/keymap.go b/internal/keymap/keymap.go index fac7da5..3c01b5e 100644 --- a/internal/keymap/keymap.go +++ b/internal/keymap/keymap.go @@ -14,6 +14,7 @@ type KeyMap struct { PageUp key.Binding PageDown key.Binding Filter key.Binding + FilterRegex key.Binding ToggleFullHelp key.Binding GotoTop key.Binding GotoBottom key.Binding @@ -63,6 +64,10 @@ func GetDefaultKeys() KeyMap { key.WithKeys("f"), key.WithHelp("f", "Filter"), ), + FilterRegex: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "RegExp Filter"), + ), ToggleFullHelp: key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "Help"), @@ -92,5 +97,6 @@ func (k KeyMap) FullHelp() [][]key.Binding { {k.PageUp, k.PageDown}, {k.GotoTop, k.GotoBottom}, {k.ToggleFullHelp, k.Exit}, + {k.FilterRegex}, } } diff --git a/internal/pkg/source/entry.go b/internal/pkg/source/entry.go index 0f77a0e..918c9e6 100644 --- a/internal/pkg/source/entry.go +++ b/internal/pkg/source/entry.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "regexp" "strconv" "strings" "time" @@ -117,6 +118,36 @@ func (entries LazyLogEntries) Filter(term string) (LazyLogEntries, error) { }, nil } +// FilterRegEx filters entries by regex ignoring case +func (entries LazyLogEntries) FilterRegExp(regex string) (LazyLogEntries, error) { + if regex == "" { + return entries, nil + } + + re, err := regexp.Compile(regex) + if err != nil { + return LazyLogEntries{}, err + } + + filtered := make([]LazyLogEntry, 0, len(entries.Entries)) + + for _, f := range entries.Entries { + line, err := f.Line(entries.Seeker) + if err != nil { + return LazyLogEntries{}, err + } + + if re.Match([]byte(bytes.ToLower(line))) { + filtered = append(filtered, f) + } + } + + return LazyLogEntries{ + Seeker: entries.Seeker, + Entries: filtered, + }, nil +} + func parseField( parsedLine any, field config.Field, From 1e9d0cc9d6b0d9fc5ee48649fad6bbb805eb7d85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B5=D0=B3=20=D0=A2=D0=B0=D0=BB=D0=B0=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D0=B2?= Date: Thu, 18 Dec 2025 20:58:31 +0300 Subject: [PATCH 3/3] Added case ignore for regexp filtering --- internal/pkg/source/entry.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/pkg/source/entry.go b/internal/pkg/source/entry.go index 918c9e6..839a897 100644 --- a/internal/pkg/source/entry.go +++ b/internal/pkg/source/entry.go @@ -124,7 +124,8 @@ func (entries LazyLogEntries) FilterRegExp(regex string) (LazyLogEntries, error) return entries, nil } - re, err := regexp.Compile(regex) + re, err := regexp.Compile(strings.ToLower(regex)) + if err != nil { return LazyLogEntries{}, err }