Added logs filter state management saperate from the panel list filter, Implemented the stream filtering code to support both tty and non-tty logs with ASCII filter support, Refactor filtering code to support both list filter and logs filter, Added new views for log filter with its new keybindings

This commit is contained in:
ManManavadaria 2025-12-31 12:33:10 +05:30
parent f4fc3669ca
commit 8a98325f3a
11 changed files with 526 additions and 18 deletions

View file

@ -64,6 +64,16 @@ logs:
timestamps: false
since: '60m' # set to '' to show all logs
tail: '' # set to 200 to show last 200 lines of logs
### Logs Filtering
When viewing container or service logs in the main panel, you can filter the log output in real-time:
- Press `/` while viewing logs to open the filter prompt
- Type a search string to filter log lines (case-sensitive substring match)
- Press `Enter` to commit the filter and return to viewing filtered logs
- Press `Esc` to cancel the filter and return to unfiltered logs
commandTemplates:
dockerCompose: docker compose # Determines the Docker Compose command to run, referred to as .DockerCompose in commandTemplates
restartService: '{{ .DockerCompose }} restart {{ .Service.Name }}'

View file

@ -29,7 +29,7 @@ func (gui *Gui) getWindowDimensions(informationStr string, appStatus string) map
sidePanelsDirection = boxlayout.ROW
}
showInfoSection := gui.Config.UserConfig.Gui.ShowBottomLine || gui.State.Filter.active
showInfoSection := gui.Config.UserConfig.Gui.ShowBottomLine || gui.State.Filter.active || gui.State.LogsFilter.active
infoSectionSize := 0
if showInfoSection {
infoSectionSize = 1
@ -112,6 +112,19 @@ func (gui *Gui) infoSectionChildren(informationStr string, appStatus string) []*
}...)
}
if gui.State.LogsFilter.active {
return append(result, []*boxlayout.Box{
{
Window: "logsfilterPrefix",
Size: runewidth.StringWidth(gui.logsFilterPrompt()),
},
{
Window: "logsfilter",
Weight: 1,
},
}...)
}
result = append(result,
[]*boxlayout.Box{
{

View file

@ -1,11 +1,14 @@
package gui
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os"
"os/signal"
"strings"
"time"
"github.com/docker/docker/api/types/container"
@ -136,12 +139,26 @@ func (gui *Gui) writeContainerLogs(ctr *commands.Container, ctx context.Context,
}
}
var filterString string
var hasFilter bool
if gui.State.LogsFilter.active {
filterString = gui.State.LogsFilter.needle
hasFilter = filterString != ""
}
if ctr.Details.Config.Tty {
if hasFilter {
return gui.filterTTYLogs(readCloser, writer, filterString, ctx)
}
_, err = io.Copy(writer, readCloser)
if err != nil {
return err
}
} else {
if hasFilter {
return gui.filterNonTTYLogs(readCloser, writer, filterString, ctx)
}
_, err = stdcopy.StdCopy(writer, writer, readCloser)
if err != nil {
return err
@ -150,3 +167,74 @@ func (gui *Gui) writeContainerLogs(ctr *commands.Container, ctx context.Context,
return nil
}
// filterTTYLogs filters TTY logs line by line based on the filter string
func (gui *Gui) filterTTYLogs(reader io.Reader, writer io.Writer, filter string, ctx context.Context) error {
return streamFilterLines(reader, writer, filter, ctx)
}
// filterNonTTYLogs filters non-TTY logs (stdout/stderr) line by line
func (gui *Gui) filterNonTTYLogs(reader io.Reader, writer io.Writer, filter string, ctx context.Context) error {
pipeReader, pipeWriter := io.Pipe()
go func() {
defer pipeWriter.Close()
done := make(chan struct{})
go func() {
select {
case <-ctx.Done():
pipeWriter.CloseWithError(ctx.Err())
case <-done:
}
}()
_, err := stdcopy.StdCopy(pipeWriter, pipeWriter, reader)
close(done)
if err != nil {
pipeWriter.CloseWithError(err)
}
}()
defer pipeReader.Close()
return streamFilterLines(pipeReader, writer, filter, ctx)
}
func streamFilterLines(reader io.Reader, writer io.Writer, filter string, ctx context.Context) error {
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 4*1024), 10*1024*1024)
// Pre-convert filter to bytes for faster comparison when filter is ASCII
filterBytes := []byte(filter)
isASCIIFilter := len(filterBytes) == len(filter)
for scanner.Scan() {
select {
case <-ctx.Done():
return nil
default:
}
line := scanner.Bytes()
var shouldWrite bool
if isASCIIFilter {
shouldWrite = bytes.Contains(line, filterBytes)
} else {
// For non-ASCII filters, we need string conversion for proper UTF-8 handling
shouldWrite = strings.Contains(string(line), filter)
}
if shouldWrite {
if _, err := writer.Write(line); err != nil {
return err
}
if _, err := writer.Write([]byte("\n")); err != nil {
return err
}
}
}
return scanner.Err()
}

View file

@ -0,0 +1,182 @@
package gui
import (
"bytes"
"context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestStreamFilterLines(t *testing.T) {
tests := []struct {
name string
input string
filter string
expected string
}{
{
name: "simple match - single line",
input: "hello world\n",
filter: "hello",
expected: "hello world\n",
},
{
name: "simple match - multiple lines",
input: "hello world\nfoo bar\nbaz qux\n",
filter: "foo",
expected: "foo bar\n",
},
{
name: "multiple matches",
input: "hello world\nhello again\nfoo bar\n",
filter: "hello",
expected: "hello world\nhello again\n",
},
{
name: "no matches",
input: "hello world\nfoo bar\nbaz qux\n",
filter: "xyz",
expected: "",
},
{
name: "empty filter - matches all",
input: "hello world\nfoo bar\n",
filter: "",
expected: "hello world\nfoo bar\n",
},
{
name: "case sensitive match",
input: "Hello World\nhello world\nHELLO WORLD\n",
filter: "hello",
expected: "hello world\n",
},
{
name: "partial word match",
input: "hello world\nhelloworld\n",
filter: "hello",
expected: "hello world\nhelloworld\n",
},
{
name: "filter in middle of line",
input: "prefix hello suffix\n",
filter: "hello",
expected: "prefix hello suffix\n",
},
{
name: "empty input",
input: "",
filter: "hello",
expected: "",
},
{
name: "newline only lines",
input: "\n\n\n",
filter: "",
expected: "\n\n\n",
},
{
name: "filter with special characters",
input: "error: something went wrong\ninfo: all good\n",
filter: "error:",
expected: "error: something went wrong\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reader := strings.NewReader(tt.input)
writer := &bytes.Buffer{}
ctx := context.Background()
err := streamFilterLines(reader, writer, tt.filter, ctx)
assert.NoError(t, err)
assert.Equal(t, tt.expected, writer.String())
})
}
}
func TestStreamFilterLines_ContextCancellation(t *testing.T) {
input := strings.Repeat("hello world\n", 1000)
reader := strings.NewReader(input)
writer := &bytes.Buffer{}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := streamFilterLines(reader, writer, "hello", ctx)
assert.NoError(t, err)
}
func TestStreamFilterLines_NonASCIIFilter(t *testing.T) {
tests := []struct {
name string
input string
filter string
expected string
}{
{
name: "UTF-8 characters",
input: "hello 世界\nfoo bar\n",
filter: "世界",
expected: "hello 世界\n",
},
{
name: "emoji filter",
input: "error 🚨 occurred\ninfo: all good\n",
filter: "🚨",
expected: "error 🚨 occurred\n",
},
{
name: "mixed ASCII and UTF-8",
input: "hello 世界 world\nfoo bar\n",
filter: "世界",
expected: "hello 世界 world\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reader := strings.NewReader(tt.input)
writer := &bytes.Buffer{}
ctx := context.Background()
err := streamFilterLines(reader, writer, tt.filter, ctx)
assert.NoError(t, err)
assert.Equal(t, tt.expected, writer.String())
})
}
}
func TestStreamFilterLines_LongLines(t *testing.T) {
longLine := strings.Repeat("a", 100000) + " target " + strings.Repeat("b", 100000) + "\n"
shortLine := "target\n"
input := longLine + shortLine
reader := strings.NewReader(input)
writer := &bytes.Buffer{}
ctx := context.Background()
err := streamFilterLines(reader, writer, "target", ctx)
assert.NoError(t, err)
assert.Contains(t, writer.String(), "target")
assert.Equal(t, 2, strings.Count(writer.String(), "target"))
}
func TestStreamFilterLines_EmptyLines(t *testing.T) {
input := "line with content\n\nanother line\n\n\n"
reader := strings.NewReader(input)
writer := &bytes.Buffer{}
ctx := context.Background()
err := streamFilterLines(reader, writer, "", ctx)
assert.NoError(t, err)
lines := strings.Split(writer.String(), "\n")
assert.GreaterOrEqual(t, len(lines), 5)
}

View file

@ -4,8 +4,37 @@ import (
"fmt"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazydocker/pkg/gui/panels"
)
type filterConfig struct {
active *bool
panel *panels.ISideListPanel
needle *string
view *gocui.View
isLogs bool
}
func (gui *Gui) listFilterConfig() filterConfig {
return filterConfig{
active: &gui.State.Filter.active,
panel: &gui.State.Filter.panel,
needle: &gui.State.Filter.needle,
view: gui.Views.Filter,
isLogs: false,
}
}
func (gui *Gui) logsFilterConfig() filterConfig {
return filterConfig{
active: &gui.State.LogsFilter.active,
panel: &gui.State.LogsFilter.panel,
needle: &gui.State.LogsFilter.needle,
view: gui.Views.LogsFilter,
isLogs: true,
}
}
func (gui *Gui) handleOpenFilter() error {
panel, ok := gui.currentListPanel()
if !ok {
@ -22,17 +51,70 @@ func (gui *Gui) handleOpenFilter() error {
return gui.switchFocus(gui.Views.Filter)
}
func (gui *Gui) onNewFilterNeedle(value string) error {
gui.State.Filter.needle = value
gui.ResetOrigin(gui.State.Filter.panel.GetView())
return gui.State.Filter.panel.RerenderList()
func (gui *Gui) handleOpenLogsFilter() error {
panel, ok := gui.currentSidePanel()
// If not focused (e.g. we are in 'main'), try to get the side panel from the view stack
if !ok {
p, ok := gui.sidePanelByViewName(gui.currentSideViewName())
if !ok {
return nil
}
panel = p
}
if !gui.IsCurrentView(gui.GetMainView()) {
return nil
}
if !gui.isMainTabActive("-logs") {
return nil
}
gui.State.LogsFilter.active = true
gui.State.LogsFilter.panel = panel
gui.State.LogsFilter.needle = ""
return gui.switchFocus(gui.Views.LogsFilter)
}
func (gui *Gui) wrapEditor(f func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool) func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool {
func (gui *Gui) onNewFilterNeedle(value string) error {
return gui.handleNewFilterNeedle(gui.listFilterConfig(), value)
}
func (gui *Gui) onNewLogsFilterNeedle(value string) error {
return gui.handleNewFilterNeedle(gui.logsFilterConfig(), value)
}
func (gui *Gui) handleNewFilterNeedle(config filterConfig, value string) error {
if config.isLogs && *config.panel == nil {
return nil
}
*config.needle = value
panel := *config.panel
if panel == nil {
return nil
}
if config.isLogs {
gui.State.Panels.Main.ObjectKey = ""
return panel.HandleSelect()
}
gui.ResetOrigin(panel.GetView())
return panel.RerenderList()
}
func (gui *Gui) wrapEditorBase(
f func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool,
onNeedleChange func(string) error,
) func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool {
return func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool {
matched := f(v, key, ch, mod)
if matched {
if err := gui.onNewFilterNeedle(v.TextArea.GetContent()); err != nil {
if err := onNeedleChange(v.TextArea.GetContent()); err != nil {
gui.Log.Error(err)
}
}
@ -40,41 +122,90 @@ func (gui *Gui) wrapEditor(f func(v *gocui.View, key gocui.Key, ch rune, mod goc
}
}
func (gui *Gui) wrapEditor(f func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool) func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool {
return gui.wrapEditorBase(f, gui.onNewFilterNeedle)
}
func (gui *Gui) wrapLogsEditor(f func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool) func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool {
return gui.wrapEditorBase(f, gui.onNewLogsFilterNeedle)
}
func (gui *Gui) escapeFilterPrompt() error {
if err := gui.clearFilter(); err != nil {
return gui.escapeFilterPromptWithConfig(gui.listFilterConfig())
}
func (gui *Gui) escapeLogsFilterPrompt() error {
return gui.escapeFilterPromptWithConfig(gui.logsFilterConfig())
}
func (gui *Gui) escapeFilterPromptWithConfig(config filterConfig) error {
if err := gui.clearFilterWithConfig(config); err != nil {
return err
}
if config.isLogs {
gui.removeViewFromStack(config.view)
}
return gui.returnFocus()
}
func (gui *Gui) clearFilter() error {
gui.State.Filter.needle = ""
gui.State.Filter.active = false
panel := gui.State.Filter.panel
gui.State.Filter.panel = nil
gui.Views.Filter.ClearTextArea()
return gui.clearFilterWithConfig(gui.listFilterConfig())
}
func (gui *Gui) clearLogsFilter() error {
return gui.clearFilterWithConfig(gui.logsFilterConfig())
}
func (gui *Gui) clearFilterWithConfig(config filterConfig) error {
*config.needle = ""
*config.active = false
panel := *config.panel
*config.panel = nil
config.view.ClearTextArea()
if panel == nil {
return nil
}
gui.ResetOrigin(panel.GetView())
if config.isLogs {
gui.State.Panels.Main.ObjectKey = ""
return panel.HandleSelect()
}
gui.ResetOrigin(panel.GetView())
return panel.RerenderList()
}
// returns to the list view with the filter still applied
func (gui *Gui) commitFilter() error {
if gui.State.Filter.needle == "" {
if err := gui.clearFilter(); err != nil {
return gui.commitFilterWithConfig(gui.listFilterConfig())
}
// returns to the main view with the filter still applied
func (gui *Gui) commitLogsFilter() error {
return gui.commitFilterWithConfig(gui.logsFilterConfig())
}
func (gui *Gui) commitFilterWithConfig(config filterConfig) error {
if *config.needle == "" {
if err := gui.clearFilterWithConfig(config); err != nil {
return err
}
}
if config.isLogs {
gui.removeViewFromStack(config.view)
}
return gui.returnFocus()
}
func (gui *Gui) filterPrompt() string {
return fmt.Sprintf("%s: ", gui.Tr.FilterPrompt)
}
func (gui *Gui) logsFilterPrompt() string {
return fmt.Sprintf("%s: ", gui.Tr.LogsFilterPrompt)
}

View file

@ -23,6 +23,8 @@ func (gui *Gui) newLineFocused(v *gocui.View) error {
return nil
case "filter":
return nil
case "logsfilter":
return nil
default:
panic(gui.Tr.NoViewMachingNewLineFocusedSwitchStatement)
}
@ -58,6 +60,12 @@ func (gui *Gui) switchFocusAux(newView *gocui.View) error {
}
}
if gui.State.LogsFilter.panel != nil && !lo.Contains(newViewStack, gui.State.LogsFilter.panel.GetView().Name()) {
if err := gui.clearLogsFilter(); err != nil {
return err
}
}
// TODO: add 'onFocusLost' hook
if !lo.Contains(newViewStack, "menu") {
gui.Views.Menu.Visible = false
@ -95,8 +103,8 @@ func (gui *Gui) removeViewFromStack(view *gocui.View) {
// Not to be called directly. Use `switchFocus` instead
func (gui *Gui) pushView(name string) {
// No matter what view we're pushing, we first remove all popup panels from the stack
// (unless it's the search view because we may be searching the menu panel)
if name != "filter" {
// (unless it's the filter views because we may be filtering the panel or logs)
if name != "filter" && name != "logsfilter" {
gui.State.ViewStack = lo.Filter(gui.State.ViewStack, func(viewName string, _ int) bool {
return !gui.isPopupPanel(viewName)
})

View file

@ -87,6 +87,9 @@ type guiState struct {
// Maintains the state of manual filtering i.e. typing in a substring
// to filter on in the current panel.
Filter filterState
// Maintains the state of container logs filtering
LogsFilter logsFilterState
}
type filterState struct {
@ -99,6 +102,17 @@ type filterState struct {
needle string
}
type logsFilterState struct {
// If true then we're either currently inside the logs filter view
// or we've committed the logs filter and we're back
active bool
panel panels.ISideListPanel
// The string that we're filtering the logs
needle string
}
// screen sizing determines how much space your selected window takes up (window
// as in panel, not your terminal's window). Sometimes you want a bit more space
// to see the contents of a panel, and this keeps track of how much maximisation

View file

@ -443,6 +443,13 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Modifier: gocui.ModNone,
Handler: gui.scrollLeftMain,
},
{
ViewName: "main",
Key: '/',
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.handleOpenLogsFilter),
Description: gui.Tr.LcFilterLogs,
},
{
ViewName: "main",
Key: gocui.KeyArrowRight,
@ -473,6 +480,18 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.escapeFilterPrompt),
},
{
ViewName: "logsfilter",
Key: gocui.KeyEnter,
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.commitLogsFilter),
},
{
ViewName: "logsfilter",
Key: gocui.KeyEsc,
Modifier: gocui.ModNone,
Handler: wrappedHandler(gui.escapeLogsFilterPrompt),
},
{
ViewName: "",
Key: 'J',

View file

@ -349,6 +349,16 @@ func (gui *Gui) currentSidePanel() (panels.ISideListPanel, bool) {
return nil, false
}
func (gui *Gui) sidePanelByViewName(viewName string) (panels.ISideListPanel, bool) {
for _, sidePanel := range gui.allSidePanels() {
if sidePanel.GetView().Name() == viewName {
return sidePanel, true
}
}
return nil, false
}
// returns the current list panel. If no list panel is focused, returns false.
func (gui *Gui) currentListPanel() (panels.ISideListPanel, bool) {
viewName := gui.currentViewName()
@ -380,3 +390,13 @@ func (gui *Gui) allListPanels() []panels.ISideListPanel {
func (gui *Gui) IsCurrentView(view *gocui.View) bool {
return view == gui.CurrentView()
}
func (gui *Gui) isMainTabActive(key string) bool {
objectKey := gui.State.Panels.Main.ObjectKey
if objectKey != "" {
if strings.HasSuffix(objectKey, key) {
return true
}
}
return false
}

View file

@ -46,6 +46,12 @@ type Views struct {
// appears next to the SearchPrefix view, it's where you type in the search string
Filter *gocui.View
// text that prompts you to enter text in the LogsFilter view
LogsFilter *gocui.View
// appears next to the SearchPrefix view, it's where you type in the log search string
LogsFilterPrefix *gocui.View
// popups
Confirmation *gocui.View
Menu *gocui.View
@ -81,6 +87,8 @@ func (gui *Gui) orderedViewNameMappings() []viewNameMapping {
{viewPtr: &gui.Views.Information, name: "information", autoPosition: true},
{viewPtr: &gui.Views.Filter, name: "filter", autoPosition: true},
{viewPtr: &gui.Views.FilterPrefix, name: "filterPrefix", autoPosition: true},
{viewPtr: &gui.Views.LogsFilter, name: "logsfilter", autoPosition: true},
{viewPtr: &gui.Views.LogsFilterPrefix, name: "logsfilterPrefix", autoPosition: true},
// popups.
{viewPtr: &gui.Views.Menu, name: "menu", autoPosition: false},
@ -178,6 +186,16 @@ func (gui *Gui) createAllViews() error {
gui.Views.Filter.Frame = false
gui.Views.Filter.Editor = gocui.EditorFunc(gui.wrapEditor(gocui.SimpleEditor))
gui.Views.LogsFilterPrefix.BgColor = gocui.ColorDefault
gui.Views.LogsFilterPrefix.FgColor = gocui.ColorGreen
gui.Views.LogsFilterPrefix.Frame = false
gui.Views.LogsFilter.BgColor = gocui.ColorDefault
gui.Views.LogsFilter.FgColor = gocui.ColorGreen
gui.Views.LogsFilter.Editable = true
gui.Views.LogsFilter.Frame = false
gui.Views.LogsFilter.Editor = gocui.EditorFunc(gui.wrapLogsEditor(gocui.SimpleEditor))
return nil
}
@ -187,6 +205,7 @@ func (gui *Gui) setInitialViewContent() error {
}
_ = gui.setViewContent(gui.Views.FilterPrefix, gui.filterPrompt())
_ = gui.setViewContent(gui.Views.LogsFilterPrefix, gui.logsFilterPrompt())
return nil
}

View file

@ -127,7 +127,9 @@ type TranslationSet struct {
LcNextScreenMode string
LcPrevScreenMode string
LcFilterLogs string
FilterPrompt string
LogsFilterPrompt string
FocusProjects string
FocusServices string
@ -269,7 +271,9 @@ func englishSet() TranslationSet {
LcNextScreenMode: "next screen mode (normal/half/fullscreen)",
LcPrevScreenMode: "prev screen mode",
LcFilterLogs: "filter logs",
FilterPrompt: "filter",
LogsFilterPrompt: "Log Filter",
FocusProjects: "focus projects panel",
FocusServices: "focus services panel",