This commit is contained in:
Man Manavadaria 2026-04-19 15:25:30 -05:00 committed by GitHub
commit 6e1c589333
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 535 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

@ -98,6 +98,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<pre>
<kbd>esc</kbd>: zurück
<kbd>/</kbd>: filter logs
</pre>
## Global

View file

@ -98,6 +98,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<pre>
<kbd>esc</kbd>: return
<kbd>/</kbd>: filter logs
</pre>
## Global

View file

@ -98,6 +98,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<pre>
<kbd>esc</kbd>: regresar
<kbd>/</kbd>: filter logs
</pre>
## Global

View file

@ -98,6 +98,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<pre>
<kbd>esc</kbd>: retour
<kbd>/</kbd>: filter logs
</pre>
## Global

View file

@ -98,6 +98,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<pre>
<kbd>esc</kbd>: terug
<kbd>/</kbd>: filter logs
</pre>
## Globaal

View file

@ -98,6 +98,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<pre>
<kbd>esc</kbd>: powrót
<kbd>/</kbd>: filter logs
</pre>
## Globalne

View file

@ -98,6 +98,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<pre>
<kbd>esc</kbd>: retornar
<kbd>/</kbd>: filter logs
</pre>
## Global

View file

@ -98,6 +98,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<pre>
<kbd>esc</kbd>: dönüş
<kbd>/</kbd>: filter logs
</pre>
## Global

View file

@ -98,6 +98,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<pre>
<kbd>esc</kbd>: 返回
<kbd>/</kbd>: filter logs
</pre>
## 全局

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},
@ -180,6 +188,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
}
@ -189,6 +207,7 @@ func (gui *Gui) setInitialViewContent() error {
}
_ = gui.setViewContent(gui.Views.FilterPrefix, gui.filterPrompt())
_ = gui.setViewContent(gui.Views.LogsFilterPrefix, gui.logsFilterPrompt())
return nil
}

View file

@ -128,7 +128,9 @@ type TranslationSet struct {
LcNextScreenMode string
LcPrevScreenMode string
LcFilterLogs string
FilterPrompt string
LogsFilterPrompt string
FocusProjects string
FocusServices string
@ -271,7 +273,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",