diff --git a/docs/Config.md b/docs/Config.md index d386ff31..e5613bcc 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -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 }}' diff --git a/pkg/gui/arrangement.go b/pkg/gui/arrangement.go index bcd4aa1c..a0c0e6e7 100644 --- a/pkg/gui/arrangement.go +++ b/pkg/gui/arrangement.go @@ -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{ { diff --git a/pkg/gui/container_logs.go b/pkg/gui/container_logs.go index bdb81608..6c920d89 100644 --- a/pkg/gui/container_logs.go +++ b/pkg/gui/container_logs.go @@ -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() +} diff --git a/pkg/gui/container_logs_test.go b/pkg/gui/container_logs_test.go new file mode 100644 index 00000000..803378ca --- /dev/null +++ b/pkg/gui/container_logs_test.go @@ -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) +} diff --git a/pkg/gui/filtering.go b/pkg/gui/filtering.go index 37cf0c1b..951fb212 100644 --- a/pkg/gui/filtering.go +++ b/pkg/gui/filtering.go @@ -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) +} diff --git a/pkg/gui/focus.go b/pkg/gui/focus.go index cc414a53..542a5cb2 100644 --- a/pkg/gui/focus.go +++ b/pkg/gui/focus.go @@ -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) }) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index fa6199f3..3b1a7c52 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -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 diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index df706024..6874f196 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -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', diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index 6acbb18c..dbcf6129 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -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 +} diff --git a/pkg/gui/views.go b/pkg/gui/views.go index 3de6d61a..846fa4f9 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -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 } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index d1ed0320..34e9dbac 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -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",