mirror of
https://github.com/jesseduffield/lazydocker.git
synced 2026-07-25 08:31:03 +00:00
Merge bf0a4ae2ec into 7e7aadc207
This commit is contained in:
commit
6e1c589333
20 changed files with 535 additions and 18 deletions
|
|
@ -64,6 +64,16 @@ logs:
|
||||||
timestamps: false
|
timestamps: false
|
||||||
since: '60m' # set to '' to show all logs
|
since: '60m' # set to '' to show all logs
|
||||||
tail: '' # set to 200 to show last 200 lines of 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:
|
commandTemplates:
|
||||||
dockerCompose: docker compose # Determines the Docker Compose command to run, referred to as .DockerCompose in commandTemplates
|
dockerCompose: docker compose # Determines the Docker Compose command to run, referred to as .DockerCompose in commandTemplates
|
||||||
restartService: '{{ .DockerCompose }} restart {{ .Service.Name }}'
|
restartService: '{{ .DockerCompose }} restart {{ .Service.Name }}'
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
<kbd>esc</kbd>: zurück
|
<kbd>esc</kbd>: zurück
|
||||||
|
<kbd>/</kbd>: filter logs
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
## Global
|
## Global
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
<kbd>esc</kbd>: return
|
<kbd>esc</kbd>: return
|
||||||
|
<kbd>/</kbd>: filter logs
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
## Global
|
## Global
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
<kbd>esc</kbd>: regresar
|
<kbd>esc</kbd>: regresar
|
||||||
|
<kbd>/</kbd>: filter logs
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
## Global
|
## Global
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
<kbd>esc</kbd>: retour
|
<kbd>esc</kbd>: retour
|
||||||
|
<kbd>/</kbd>: filter logs
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
## Global
|
## Global
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
<kbd>esc</kbd>: terug
|
<kbd>esc</kbd>: terug
|
||||||
|
<kbd>/</kbd>: filter logs
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
## Globaal
|
## Globaal
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
<kbd>esc</kbd>: powrót
|
<kbd>esc</kbd>: powrót
|
||||||
|
<kbd>/</kbd>: filter logs
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
## Globalne
|
## Globalne
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
<kbd>esc</kbd>: retornar
|
<kbd>esc</kbd>: retornar
|
||||||
|
<kbd>/</kbd>: filter logs
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
## Global
|
## Global
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
<kbd>esc</kbd>: dönüş
|
<kbd>esc</kbd>: dönüş
|
||||||
|
<kbd>/</kbd>: filter logs
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
## Global
|
## Global
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
<kbd>esc</kbd>: 返回
|
<kbd>esc</kbd>: 返回
|
||||||
|
<kbd>/</kbd>: filter logs
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
## 全局
|
## 全局
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ func (gui *Gui) getWindowDimensions(informationStr string, appStatus string) map
|
||||||
sidePanelsDirection = boxlayout.ROW
|
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
|
infoSectionSize := 0
|
||||||
if showInfoSection {
|
if showInfoSection {
|
||||||
infoSectionSize = 1
|
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,
|
result = append(result,
|
||||||
[]*boxlayout.Box{
|
[]*boxlayout.Box{
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
package gui
|
package gui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"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 ctr.Details.Config.Tty {
|
||||||
|
if hasFilter {
|
||||||
|
return gui.filterTTYLogs(readCloser, writer, filterString, ctx)
|
||||||
|
}
|
||||||
_, err = io.Copy(writer, readCloser)
|
_, err = io.Copy(writer, readCloser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if hasFilter {
|
||||||
|
return gui.filterNonTTYLogs(readCloser, writer, filterString, ctx)
|
||||||
|
}
|
||||||
_, err = stdcopy.StdCopy(writer, writer, readCloser)
|
_, err = stdcopy.StdCopy(writer, writer, readCloser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -150,3 +167,74 @@ func (gui *Gui) writeContainerLogs(ctr *commands.Container, ctx context.Context,
|
||||||
|
|
||||||
return nil
|
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()
|
||||||
|
}
|
||||||
|
|
|
||||||
182
pkg/gui/container_logs_test.go
Normal file
182
pkg/gui/container_logs_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -4,8 +4,37 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/jesseduffield/gocui"
|
"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 {
|
func (gui *Gui) handleOpenFilter() error {
|
||||||
panel, ok := gui.currentListPanel()
|
panel, ok := gui.currentListPanel()
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -22,17 +51,70 @@ func (gui *Gui) handleOpenFilter() error {
|
||||||
return gui.switchFocus(gui.Views.Filter)
|
return gui.switchFocus(gui.Views.Filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gui *Gui) onNewFilterNeedle(value string) error {
|
func (gui *Gui) handleOpenLogsFilter() error {
|
||||||
gui.State.Filter.needle = value
|
panel, ok := gui.currentSidePanel()
|
||||||
gui.ResetOrigin(gui.State.Filter.panel.GetView())
|
|
||||||
return gui.State.Filter.panel.RerenderList()
|
// 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 {
|
return func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool {
|
||||||
matched := f(v, key, ch, mod)
|
matched := f(v, key, ch, mod)
|
||||||
if matched {
|
if matched {
|
||||||
if err := gui.onNewFilterNeedle(v.TextArea.GetContent()); err != nil {
|
if err := onNeedleChange(v.TextArea.GetContent()); err != nil {
|
||||||
gui.Log.Error(err)
|
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 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.isLogs {
|
||||||
|
gui.removeViewFromStack(config.view)
|
||||||
|
}
|
||||||
|
|
||||||
return gui.returnFocus()
|
return gui.returnFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gui *Gui) clearFilter() error {
|
func (gui *Gui) clearFilter() error {
|
||||||
gui.State.Filter.needle = ""
|
return gui.clearFilterWithConfig(gui.listFilterConfig())
|
||||||
gui.State.Filter.active = false
|
}
|
||||||
panel := gui.State.Filter.panel
|
|
||||||
gui.State.Filter.panel = nil
|
func (gui *Gui) clearLogsFilter() error {
|
||||||
gui.Views.Filter.ClearTextArea()
|
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 {
|
if panel == nil {
|
||||||
return 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()
|
return panel.RerenderList()
|
||||||
}
|
}
|
||||||
|
|
||||||
// returns to the list view with the filter still applied
|
// returns to the list view with the filter still applied
|
||||||
func (gui *Gui) commitFilter() error {
|
func (gui *Gui) commitFilter() error {
|
||||||
if gui.State.Filter.needle == "" {
|
return gui.commitFilterWithConfig(gui.listFilterConfig())
|
||||||
if err := gui.clearFilter(); err != nil {
|
}
|
||||||
|
|
||||||
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.isLogs {
|
||||||
|
gui.removeViewFromStack(config.view)
|
||||||
|
}
|
||||||
|
|
||||||
return gui.returnFocus()
|
return gui.returnFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gui *Gui) filterPrompt() string {
|
func (gui *Gui) filterPrompt() string {
|
||||||
return fmt.Sprintf("%s: ", gui.Tr.FilterPrompt)
|
return fmt.Sprintf("%s: ", gui.Tr.FilterPrompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (gui *Gui) logsFilterPrompt() string {
|
||||||
|
return fmt.Sprintf("%s: ", gui.Tr.LogsFilterPrompt)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ func (gui *Gui) newLineFocused(v *gocui.View) error {
|
||||||
return nil
|
return nil
|
||||||
case "filter":
|
case "filter":
|
||||||
return nil
|
return nil
|
||||||
|
case "logsfilter":
|
||||||
|
return nil
|
||||||
default:
|
default:
|
||||||
panic(gui.Tr.NoViewMachingNewLineFocusedSwitchStatement)
|
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
|
// TODO: add 'onFocusLost' hook
|
||||||
if !lo.Contains(newViewStack, "menu") {
|
if !lo.Contains(newViewStack, "menu") {
|
||||||
gui.Views.Menu.Visible = false
|
gui.Views.Menu.Visible = false
|
||||||
|
|
@ -95,8 +103,8 @@ func (gui *Gui) removeViewFromStack(view *gocui.View) {
|
||||||
// Not to be called directly. Use `switchFocus` instead
|
// Not to be called directly. Use `switchFocus` instead
|
||||||
func (gui *Gui) pushView(name string) {
|
func (gui *Gui) pushView(name string) {
|
||||||
// No matter what view we're pushing, we first remove all popup panels from the stack
|
// 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)
|
// (unless it's the filter views because we may be filtering the panel or logs)
|
||||||
if name != "filter" {
|
if name != "filter" && name != "logsfilter" {
|
||||||
gui.State.ViewStack = lo.Filter(gui.State.ViewStack, func(viewName string, _ int) bool {
|
gui.State.ViewStack = lo.Filter(gui.State.ViewStack, func(viewName string, _ int) bool {
|
||||||
return !gui.isPopupPanel(viewName)
|
return !gui.isPopupPanel(viewName)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,9 @@ type guiState struct {
|
||||||
// Maintains the state of manual filtering i.e. typing in a substring
|
// Maintains the state of manual filtering i.e. typing in a substring
|
||||||
// to filter on in the current panel.
|
// to filter on in the current panel.
|
||||||
Filter filterState
|
Filter filterState
|
||||||
|
|
||||||
|
// Maintains the state of container logs filtering
|
||||||
|
LogsFilter logsFilterState
|
||||||
}
|
}
|
||||||
|
|
||||||
type filterState struct {
|
type filterState struct {
|
||||||
|
|
@ -99,6 +102,17 @@ type filterState struct {
|
||||||
needle string
|
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
|
// 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
|
// 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
|
// to see the contents of a panel, and this keeps track of how much maximisation
|
||||||
|
|
|
||||||
|
|
@ -443,6 +443,13 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
|
||||||
Modifier: gocui.ModNone,
|
Modifier: gocui.ModNone,
|
||||||
Handler: gui.scrollLeftMain,
|
Handler: gui.scrollLeftMain,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ViewName: "main",
|
||||||
|
Key: '/',
|
||||||
|
Modifier: gocui.ModNone,
|
||||||
|
Handler: wrappedHandler(gui.handleOpenLogsFilter),
|
||||||
|
Description: gui.Tr.LcFilterLogs,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ViewName: "main",
|
ViewName: "main",
|
||||||
Key: gocui.KeyArrowRight,
|
Key: gocui.KeyArrowRight,
|
||||||
|
|
@ -473,6 +480,18 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
|
||||||
Modifier: gocui.ModNone,
|
Modifier: gocui.ModNone,
|
||||||
Handler: wrappedHandler(gui.escapeFilterPrompt),
|
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: "",
|
ViewName: "",
|
||||||
Key: 'J',
|
Key: 'J',
|
||||||
|
|
|
||||||
|
|
@ -349,6 +349,16 @@ func (gui *Gui) currentSidePanel() (panels.ISideListPanel, bool) {
|
||||||
return nil, false
|
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.
|
// returns the current list panel. If no list panel is focused, returns false.
|
||||||
func (gui *Gui) currentListPanel() (panels.ISideListPanel, bool) {
|
func (gui *Gui) currentListPanel() (panels.ISideListPanel, bool) {
|
||||||
viewName := gui.currentViewName()
|
viewName := gui.currentViewName()
|
||||||
|
|
@ -380,3 +390,13 @@ func (gui *Gui) allListPanels() []panels.ISideListPanel {
|
||||||
func (gui *Gui) IsCurrentView(view *gocui.View) bool {
|
func (gui *Gui) IsCurrentView(view *gocui.View) bool {
|
||||||
return view == gui.CurrentView()
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,12 @@ type Views struct {
|
||||||
// appears next to the SearchPrefix view, it's where you type in the search string
|
// appears next to the SearchPrefix view, it's where you type in the search string
|
||||||
Filter *gocui.View
|
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
|
// popups
|
||||||
Confirmation *gocui.View
|
Confirmation *gocui.View
|
||||||
Menu *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.Information, name: "information", autoPosition: true},
|
||||||
{viewPtr: &gui.Views.Filter, name: "filter", autoPosition: true},
|
{viewPtr: &gui.Views.Filter, name: "filter", autoPosition: true},
|
||||||
{viewPtr: &gui.Views.FilterPrefix, name: "filterPrefix", 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.
|
// popups.
|
||||||
{viewPtr: &gui.Views.Menu, name: "menu", autoPosition: false},
|
{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.Frame = false
|
||||||
gui.Views.Filter.Editor = gocui.EditorFunc(gui.wrapEditor(gocui.SimpleEditor))
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,6 +207,7 @@ func (gui *Gui) setInitialViewContent() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = gui.setViewContent(gui.Views.FilterPrefix, gui.filterPrompt())
|
_ = gui.setViewContent(gui.Views.FilterPrefix, gui.filterPrompt())
|
||||||
|
_ = gui.setViewContent(gui.Views.LogsFilterPrefix, gui.logsFilterPrompt())
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,9 @@ type TranslationSet struct {
|
||||||
|
|
||||||
LcNextScreenMode string
|
LcNextScreenMode string
|
||||||
LcPrevScreenMode string
|
LcPrevScreenMode string
|
||||||
|
LcFilterLogs string
|
||||||
FilterPrompt string
|
FilterPrompt string
|
||||||
|
LogsFilterPrompt string
|
||||||
|
|
||||||
FocusProjects string
|
FocusProjects string
|
||||||
FocusServices string
|
FocusServices string
|
||||||
|
|
@ -271,7 +273,9 @@ func englishSet() TranslationSet {
|
||||||
|
|
||||||
LcNextScreenMode: "next screen mode (normal/half/fullscreen)",
|
LcNextScreenMode: "next screen mode (normal/half/fullscreen)",
|
||||||
LcPrevScreenMode: "prev screen mode",
|
LcPrevScreenMode: "prev screen mode",
|
||||||
|
LcFilterLogs: "filter logs",
|
||||||
FilterPrompt: "filter",
|
FilterPrompt: "filter",
|
||||||
|
LogsFilterPrompt: "Log Filter",
|
||||||
|
|
||||||
FocusProjects: "focus projects panel",
|
FocusProjects: "focus projects panel",
|
||||||
FocusServices: "focus services panel",
|
FocusServices: "focus services panel",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue