diff --git a/config/config.yml b/config/config.yml index e69de29b..12b0e163 100644 --- a/config/config.yml +++ b/config/config.yml @@ -0,0 +1,2 @@ +logs: + pager: "lnav" diff --git a/docs/Config.md b/docs/Config.md index d386ff31..a2f77e08 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -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 }}' diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index 0ebc1796..20668c97 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -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", diff --git a/pkg/gui/container_logs.go b/pkg/gui/container_logs.go index bdb81608..84ff6fb5 100644 --- a/pkg/gui/container_logs.go +++ b/pkg/gui/container_logs.go @@ -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 +} diff --git a/pkg/gui/focus.go b/pkg/gui/focus.go index cc414a53..5ced85e0 100644 --- a/pkg/gui/focus.go +++ b/pkg/gui/focus.go @@ -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 } diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index df706024..aa6e96ea 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -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...) +} diff --git a/pkg/gui/keybindings_test.go b/pkg/gui/keybindings_test.go new file mode 100644 index 00000000..ee3b7ae4 --- /dev/null +++ b/pkg/gui/keybindings_test.go @@ -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'") +} diff --git a/pkg/gui/services_panel.go b/pkg/gui/services_panel.go index edc894e9..7097ebc5 100644 --- a/pkg/gui/services_panel.go +++ b/pkg/gui/services_panel.go @@ -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 { diff --git a/pkg/gui/views.go b/pkg/gui/views.go index f231c3c6..f3361c6d 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -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 } diff --git a/pkg/utils/logs.go b/pkg/utils/logs.go new file mode 100644 index 00000000..00271113 --- /dev/null +++ b/pkg/utils/logs.go @@ -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 +} diff --git a/pkg/utils/logs_test.go b/pkg/utils/logs_test.go new file mode 100644 index 00000000..ded6b218 --- /dev/null +++ b/pkg/utils/logs_test.go @@ -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) + } + } + }) + } +}