This commit is contained in:
hunchul choi 2026-06-15 07:08:14 +00:00 committed by GitHub
commit a98cc2c6b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 449 additions and 3 deletions

View file

@ -0,0 +1,2 @@
logs:
pager: "lnav"

View file

@ -64,6 +64,7 @@ logs:
timestamps: false
since: '60m' # set to '' to show all logs
tail: '' # set to 200 to show last 200 lines of logs
pager: 'less -R' # command for external logs pager (e.g. 'less -R', 'lnav')
commandTemplates:
dockerCompose: docker compose # Determines the Docker Compose command to run, referred to as .DockerCompose in commandTemplates
restartService: '{{ .DockerCompose }} restart {{ .Service.Name }}'

View file

@ -344,6 +344,7 @@ type LogsConfig struct {
Timestamps bool `yaml:"timestamps,omitempty"`
Since string `yaml:"since,omitempty"`
Tail string `yaml:"tail,omitempty"`
Pager string `yaml:"pager,omitempty"` // Configuration for external logs viewer (e.g. "lnav", "less -R")
}
// GetDefaultConfig returns the application default configuration NOTE (to
@ -383,6 +384,7 @@ func GetDefaultConfig() UserConfig {
Timestamps: false,
Since: "60m",
Tail: "",
Pager: "less -R",
},
CommandTemplates: CommandTemplatesConfig{
DockerCompose: "docker compose",

View file

@ -1,16 +1,20 @@
package gui
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"strings"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/pkg/stdcopy"
"github.com/fatih/color"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/jesseduffield/lazydocker/pkg/tasks"
"github.com/jesseduffield/lazydocker/pkg/utils"
@ -136,13 +140,18 @@ func (gui *Gui) writeContainerLogs(ctr *commands.Container, ctx context.Context,
}
}
stdoutWriter := NewLogWriter(writer, false)
stderrWriter := NewLogWriter(writer, true)
defer stdoutWriter.Close()
defer stderrWriter.Close()
if ctr.Details.Config.Tty {
_, err = io.Copy(writer, readCloser)
_, err = io.Copy(stdoutWriter, readCloser)
if err != nil {
return err
}
} else {
_, err = stdcopy.StdCopy(writer, writer, readCloser)
_, err = stdcopy.StdCopy(stdoutWriter, stderrWriter, readCloser)
if err != nil {
return err
}
@ -150,3 +159,137 @@ func (gui *Gui) writeContainerLogs(ctr *commands.Container, ctx context.Context,
return nil
}
type LogWriter struct {
writer io.Writer
lineBuffer []byte
isStderr bool
}
func NewLogWriter(writer io.Writer, isStderr bool) *LogWriter {
return &LogWriter{
writer: writer,
lineBuffer: make([]byte, 0),
isStderr: isStderr,
}
}
func (lw *LogWriter) Write(p []byte) (n int, err error) {
n = len(p)
lw.lineBuffer = append(lw.lineBuffer, p...)
for {
idx := bytes.IndexByte(lw.lineBuffer, '\n')
if idx == -1 {
break
}
line := string(lw.lineBuffer[:idx])
lw.lineBuffer = lw.lineBuffer[idx+1:]
colorised := utils.ColoriseLog(line)
if lw.isStderr {
colorised = "\x1b[31;1m" + colorised + "\x1b[0m"
}
_, err = fmt.Fprintln(lw.writer, colorised)
if err != nil {
return n, err
}
}
return n, nil
}
func (lw *LogWriter) Close() error {
if len(lw.lineBuffer) > 0 {
line := string(lw.lineBuffer)
colorised := utils.ColoriseLog(line)
if lw.isStderr {
colorised = "\x1b[31;1m" + colorised + "\x1b[0m"
}
_, err := fmt.Fprint(lw.writer, colorised)
return err
}
return nil
}
func (gui *Gui) handleContainerViewLogsExternal(g *gocui.Gui, v *gocui.View) error {
ctr, err := gui.Panels.Containers.GetSelectedItem()
if err != nil {
return nil
}
return gui.handleViewLogsExternal(ctr)
}
func (gui *Gui) handleMainViewLogsExternal(g *gocui.Gui, v *gocui.View) error {
currentSideViewName := gui.currentSideViewName()
if currentSideViewName == "containers" {
return gui.handleContainerViewLogsExternal(g, v)
} else if currentSideViewName == "services" {
return gui.handleServiceViewLogsExternal(g, v)
}
return nil
}
func (gui *Gui) handleViewLogsExternal(container *commands.Container) error {
pager := gui.Config.UserConfig.Logs.Pager
if pager == "" {
return gui.createErrorPanel("No external pager configured. Please set 'logs.pager' in config.yml")
}
tmpFile, err := os.CreateTemp("", "lazydocker-*.log")
if err != nil {
return gui.createErrorPanel(err.Error())
}
tmpPath := tmpFile.Name()
defer func() {
tmpFile.Close()
_ = os.Remove(tmpPath)
}()
stop := make(chan os.Signal, 1)
defer signal.Stop(stop)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
signal.Notify(stop, os.Interrupt)
<-stop
cancel()
}()
// Start writing container logs to the temporary file in background
go func() {
_ = gui.writeContainerLogs(container, ctx, tmpFile)
}()
// Wait briefly for some logs to be written so the pager has content on start
time.Sleep(200 * time.Millisecond)
if err := gui.g.Suspend(); err != nil {
gui.Log.Error(err)
return err
}
defer func() {
if err := gui.g.Resume(); err != nil {
gui.Log.Error(err)
}
}()
var cmdStr string
if strings.Contains(pager, "{{filename}}") {
cmdStr = strings.ReplaceAll(pager, "{{filename}}", tmpPath)
} else {
cmdStr = pager + " " + tmpPath
}
cmd := exec.CommandContext(ctx, "sh", "-c", cmdStr)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
return nil
}

View file

@ -46,6 +46,23 @@ func (gui *Gui) switchFocusAux(newView *gocui.View) error {
gui.g.Cursor = newView.Editable
// Automatically control mouse capture mode depending on focus
if newView.Name() == "main" {
if gui.g.Mouse {
gui.g.Mouse = false
gocui.Screen.DisableMouse()
}
} else {
if !gui.Config.UserConfig.Gui.IgnoreMouseEvents {
if !gui.g.Mouse {
gui.g.Mouse = true
gocui.Screen.EnableMouse()
}
}
}
// Force refresh the bottom information panel with updated status text
_ = gui.renderString(gui.g, "information", gui.getInformationContent())
if err := gui.renderPanelOptions(); err != nil {
return err
}

View file

@ -3,6 +3,7 @@ package gui
import (
"fmt"
"github.com/gdamore/tcell/v2"
"github.com/jesseduffield/gocui"
)
@ -74,6 +75,7 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Modifier: gocui.ModNone,
Handler: gui.quit,
},
{
ViewName: "",
Key: gocui.KeyPgup,
@ -234,6 +236,13 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Handler: gui.handleContainerViewLogs,
Description: gui.Tr.ViewLogs,
},
{
ViewName: "containers",
Key: 'l',
Modifier: gocui.ModNone,
Handler: gui.handleContainerViewLogsExternal,
Description: "View logs with external pager",
},
{
ViewName: "containers",
Key: 'E',
@ -318,6 +327,13 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Handler: gui.handleServiceRenderLogsToMain,
Description: gui.Tr.ViewLogs,
},
{
ViewName: "services",
Key: 'l',
Modifier: gocui.ModNone,
Handler: gui.handleServiceViewLogsExternal,
Description: "View logs with external pager",
},
{
ViewName: "services",
Key: 'U',
@ -437,6 +453,13 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Handler: gui.handleExitMain,
Description: gui.Tr.Return,
},
{
ViewName: "main",
Key: 'l',
Modifier: gocui.ModNone,
Handler: gui.handleMainViewLogsExternal,
Description: "View logs with external pager",
},
{
ViewName: "main",
Key: gocui.KeyArrowLeft,
@ -587,7 +610,7 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
}
}
return bindings
return translateKeybindingsToKorean(bindings)
}
func (gui *Gui) keybindings(g *gocui.Gui) error {
@ -611,3 +634,86 @@ func wrappedHandler(f func() error) func(*gocui.Gui, *gocui.View) error {
return f()
}
}
type koreanKey struct {
key rune
modifier gocui.Modifier
}
var englishToKorean = map[rune]koreanKey{
// Lowercase
'q': {key: 'ㅂ', modifier: gocui.ModNone},
'w': {key: 'ㅈ', modifier: gocui.ModNone},
'e': {key: 'ㄷ', modifier: gocui.ModNone},
'r': {key: 'ㄱ', modifier: gocui.ModNone},
't': {key: 'ㅅ', modifier: gocui.ModNone},
'y': {key: 'ㅛ', modifier: gocui.ModNone},
'u': {key: 'ㅕ', modifier: gocui.ModNone},
'i': {key: 'ㅑ', modifier: gocui.ModNone},
'o': {key: 'ㅐ', modifier: gocui.ModNone},
'p': {key: 'ㅔ', modifier: gocui.ModNone},
'a': {key: 'ㅁ', modifier: gocui.ModNone},
's': {key: 'ㄴ', modifier: gocui.ModNone},
'd': {key: 'ㅇ', modifier: gocui.ModNone},
'f': {key: 'ㄹ', modifier: gocui.ModNone},
'g': {key: 'ㅎ', modifier: gocui.ModNone},
'h': {key: 'ㅗ', modifier: gocui.ModNone},
'j': {key: 'ㅓ', modifier: gocui.ModNone},
'k': {key: 'ㅏ', modifier: gocui.ModNone},
'l': {key: 'ㅣ', modifier: gocui.ModNone},
'z': {key: 'ㅋ', modifier: gocui.ModNone},
'x': {key: 'ㅌ', modifier: gocui.ModNone},
'c': {key: 'ㅊ', modifier: gocui.ModNone},
'v': {key: 'ㅍ', modifier: gocui.ModNone},
'b': {key: 'ㅠ', modifier: gocui.ModNone},
'n': {key: 'ㅜ', modifier: gocui.ModNone},
'm': {key: 'ㅡ', modifier: gocui.ModNone},
// Uppercase (Double Consonants - ModNone)
'Q': {key: 'ㅃ', modifier: gocui.ModNone},
'W': {key: 'ㅉ', modifier: gocui.ModNone},
'E': {key: 'ㄸ', modifier: gocui.ModNone},
'R': {key: 'ㄲ', modifier: gocui.ModNone},
'T': {key: 'ㅆ', modifier: gocui.ModNone},
'O': {key: 'ㅒ', modifier: gocui.ModNone},
'P': {key: 'ㅖ', modifier: gocui.ModNone},
// Uppercase (No Double Consonant - ModShift)
'A': {key: 'ㅁ', modifier: gocui.Modifier(tcell.ModShift)},
'S': {key: 'ㄴ', modifier: gocui.Modifier(tcell.ModShift)},
'D': {key: 'ㅇ', modifier: gocui.Modifier(tcell.ModShift)},
'F': {key: 'ㄹ', modifier: gocui.Modifier(tcell.ModShift)},
'G': {key: 'ㅎ', modifier: gocui.Modifier(tcell.ModShift)},
'H': {key: 'ㅗ', modifier: gocui.Modifier(tcell.ModShift)},
'J': {key: 'ㅓ', modifier: gocui.Modifier(tcell.ModShift)},
'K': {key: 'ㅏ', modifier: gocui.Modifier(tcell.ModShift)},
'L': {key: 'ㅣ', modifier: gocui.Modifier(tcell.ModShift)},
'Z': {key: 'ㅋ', modifier: gocui.Modifier(tcell.ModShift)},
'X': {key: 'ㅌ', modifier: gocui.Modifier(tcell.ModShift)},
'C': {key: 'ㅊ', modifier: gocui.Modifier(tcell.ModShift)},
'V': {key: 'ㅍ', modifier: gocui.Modifier(tcell.ModShift)},
'B': {key: 'ㅠ', modifier: gocui.Modifier(tcell.ModShift)},
'N': {key: 'ㅜ', modifier: gocui.Modifier(tcell.ModShift)},
'M': {key: 'ㅡ', modifier: gocui.Modifier(tcell.ModShift)},
'Y': {key: 'ㅛ', modifier: gocui.Modifier(tcell.ModShift)},
'U': {key: 'ㅕ', modifier: gocui.Modifier(tcell.ModShift)},
'I': {key: 'ㅑ', modifier: gocui.Modifier(tcell.ModShift)},
}
func translateKeybindingsToKorean(bindings []*Binding) []*Binding {
var translated []*Binding
for _, binding := range bindings {
if r, ok := binding.Key.(rune); ok {
if kor, exists := englishToKorean[r]; exists {
translated = append(translated, &Binding{
ViewName: binding.ViewName,
Handler: binding.Handler,
Key: kor.key,
Modifier: binding.Modifier | kor.modifier,
Description: binding.Description,
})
}
}
}
return append(bindings, translated...)
}

View file

@ -0,0 +1,75 @@
package gui
import (
"testing"
"github.com/gdamore/tcell/v2"
"github.com/jesseduffield/gocui"
"github.com/stretchr/testify/assert"
)
func TestTranslateKeybindingsToKorean(t *testing.T) {
inputBindings := []*Binding{
{
ViewName: "services",
Key: 's',
Modifier: gocui.ModNone,
Handler: nil,
},
{
ViewName: "services",
Key: 'S',
Modifier: gocui.ModNone,
Handler: nil,
},
{
ViewName: "services",
Key: 'l',
Modifier: gocui.ModNone,
Handler: nil,
},
{
ViewName: "global",
Key: gocui.KeyCtrlC,
Modifier: gocui.ModNone,
Handler: nil,
},
}
result := translateKeybindingsToKorean(inputBindings)
// The original bindings must remain intact, and new ones should be appended
assert.Len(t, result, len(inputBindings)+3)
// Verify original bindings are still present and unaltered
for i, b := range inputBindings {
assert.Equal(t, b.Key, result[i].Key)
assert.Equal(t, b.Modifier, result[i].Modifier)
}
// Verify translated Korean bindings are appended correctly
// 's' -> 'ㄴ' (gocui.ModNone)
// 'S' -> 'ㄴ' (gocui.Modifier(tcell.ModShift))
// 'l' -> 'ㅣ' (gocui.ModNone)
// We expect translated bindings to be appended:
foundS := false
foundShiftS := false
foundL := false
for _, b := range result[len(inputBindings):] {
if b.Key == 'ㄴ' && b.Modifier == gocui.ModNone {
foundS = true
}
if b.Key == 'ㄴ' && b.Modifier == gocui.Modifier(tcell.ModShift) {
foundShiftS = true
}
if b.Key == 'ㅣ' && b.Modifier == gocui.ModNone {
foundL = true
}
}
assert.True(t, foundS, "Expected to find translated Korean binding for 's'")
assert.True(t, foundShiftS, "Expected to find translated Korean binding for 'S'")
assert.True(t, foundL, "Expected to find translated Korean binding for 'l'")
}

View file

@ -333,6 +333,17 @@ func (gui *Gui) handleServiceRenderLogsToMain(g *gocui.Gui, v *gocui.View) error
return gui.runSubprocess(c)
}
func (gui *Gui) handleServiceViewLogsExternal(g *gocui.Gui, v *gocui.View) error {
service, err := gui.Panels.Services.GetSelectedItem()
if err != nil {
return nil
}
if service.Container == nil {
return gui.createErrorPanel("No container running for this service")
}
return gui.handleViewLogsExternal(service.Container)
}
func (gui *Gui) handleProjectUp(g *gocui.Gui, v *gocui.View) error {
project, _ := gui.Panels.Projects.GetSelectedItem()
if project != nil && project.Name != gui.DockerCommand.LocalProjectName {

View file

@ -195,6 +195,13 @@ func (gui *Gui) setInitialViewContent() error {
func (gui *Gui) getInformationContent() string {
informationStr := gui.Config.Version
mouseStatus := "Mouse: ON"
if !gui.g.Mouse {
mouseStatus = "Mouse: OFF (Drag to copy)"
}
informationStr = mouseStatus + " | " + informationStr
if !gui.g.Mouse {
return informationStr
}

41
pkg/utils/logs.go Normal file
View file

@ -0,0 +1,41 @@
package utils
import (
"regexp"
"strings"
)
var (
errRegex = regexp.MustCompile(`(?i)\b(error|err|critical|fatal)\b`)
warnRegex = regexp.MustCompile(`(?i)\b(warning|warn)\b`)
infoRegex = regexp.MustCompile(`(?i)\b(info)\b`)
debugRegex = regexp.MustCompile(`(?i)\b(debug)\b`)
timestampRegex = regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\b`)
)
// ColoriseLog applies basic ANSI syntax highlighting to typical log statements.
func ColoriseLog(line string) string {
if strings.Contains(line, "\x1b[") {
return line
}
line = timestampRegex.ReplaceAllStringFunc(line, func(ts string) string {
return "\x1b[90m" + ts + "\x1b[0m"
})
line = errRegex.ReplaceAllStringFunc(line, func(match string) string {
return "\x1b[31;1m" + match + "\x1b[0m"
})
line = warnRegex.ReplaceAllStringFunc(line, func(match string) string {
return "\x1b[33;1m" + match + "\x1b[0m"
})
line = infoRegex.ReplaceAllStringFunc(line, func(match string) string {
return "\x1b[32m" + match + "\x1b[0m"
})
line = debugRegex.ReplaceAllStringFunc(line, func(match string) string {
return "\x1b[34m" + match + "\x1b[0m"
})
return line
}

41
pkg/utils/logs_test.go Normal file
View file

@ -0,0 +1,41 @@
package utils
import (
"strings"
"testing"
)
func TestColoriseLog(t *testing.T) {
tests := []struct {
name string
input string
contains []string
}{
{
name: "Highlight error log level",
input: "ERROR: database failed to connect",
contains: []string{"\x1b[31;1mERROR\x1b[0m"},
},
{
name: "Highlight warning log level",
input: "[WARN] deprecation warning",
contains: []string{"\x1b[33;1mWARN\x1b[0m"},
},
{
name: "Highlight timestamp",
input: "2026-06-04T12:00:00Z something happened",
contains: []string{"\x1b[90m2026-06-04T12:00:00Z\x1b[0m"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ColoriseLog(tt.input)
for _, c := range tt.contains {
if !strings.Contains(got, c) {
t.Errorf("expected %q to contain %q, but it was %q", got, c, got)
}
}
})
}
}