Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
logs:
pager: "lnav"
1 change: 1 addition & 0 deletions docs/Config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}'
Expand Down
2 changes: 2 additions & 0 deletions pkg/config/app_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -383,6 +384,7 @@ func GetDefaultConfig() UserConfig {
Timestamps: false,
Since: "60m",
Tail: "",
Pager: "less -R",
},
CommandTemplates: CommandTemplatesConfig{
DockerCompose: "docker compose",
Expand Down
147 changes: 145 additions & 2 deletions pkg/gui/container_logs.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -136,17 +140,156 @@ 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
}
}

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
}
17 changes: 17 additions & 0 deletions pkg/gui/focus.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
108 changes: 107 additions & 1 deletion pkg/gui/keybindings.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package gui
import (
"fmt"

"github.com/gdamore/tcell/v2"
"github.com/jesseduffield/gocui"
)

Expand Down Expand Up @@ -74,6 +75,7 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Modifier: gocui.ModNone,
Handler: gui.quit,
},

{
ViewName: "",
Key: gocui.KeyPgup,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -587,7 +610,7 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
}
}

return bindings
return translateKeybindingsToKorean(bindings)
}

func (gui *Gui) keybindings(g *gocui.Gui) error {
Expand All @@ -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...)
}
Loading