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..839a897 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,37 @@ 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(strings.ToLower(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,