mirror of
https://github.com/jesseduffield/lazydocker.git
synced 2026-07-25 08:31:03 +00:00
Merge 0f0783ae3c into 7e7aadc207
This commit is contained in:
commit
a98cc2c6b1
11 changed files with 449 additions and 3 deletions
|
|
@ -0,0 +1,2 @@
|
||||||
|
logs:
|
||||||
|
pager: "lnav"
|
||||||
|
|
@ -64,6 +64,7 @@ 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
|
||||||
|
pager: 'less -R' # command for external logs pager (e.g. 'less -R', 'lnav')
|
||||||
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 }}'
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,7 @@ type LogsConfig struct {
|
||||||
Timestamps bool `yaml:"timestamps,omitempty"`
|
Timestamps bool `yaml:"timestamps,omitempty"`
|
||||||
Since string `yaml:"since,omitempty"`
|
Since string `yaml:"since,omitempty"`
|
||||||
Tail string `yaml:"tail,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
|
// GetDefaultConfig returns the application default configuration NOTE (to
|
||||||
|
|
@ -383,6 +384,7 @@ func GetDefaultConfig() UserConfig {
|
||||||
Timestamps: false,
|
Timestamps: false,
|
||||||
Since: "60m",
|
Since: "60m",
|
||||||
Tail: "",
|
Tail: "",
|
||||||
|
Pager: "less -R",
|
||||||
},
|
},
|
||||||
CommandTemplates: CommandTemplatesConfig{
|
CommandTemplates: CommandTemplatesConfig{
|
||||||
DockerCompose: "docker compose",
|
DockerCompose: "docker compose",
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
package gui
|
package gui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/pkg/stdcopy"
|
"github.com/docker/docker/pkg/stdcopy"
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
|
"github.com/jesseduffield/gocui"
|
||||||
"github.com/jesseduffield/lazydocker/pkg/commands"
|
"github.com/jesseduffield/lazydocker/pkg/commands"
|
||||||
"github.com/jesseduffield/lazydocker/pkg/tasks"
|
"github.com/jesseduffield/lazydocker/pkg/tasks"
|
||||||
"github.com/jesseduffield/lazydocker/pkg/utils"
|
"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 {
|
if ctr.Details.Config.Tty {
|
||||||
_, err = io.Copy(writer, readCloser)
|
_, err = io.Copy(stdoutWriter, readCloser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_, err = stdcopy.StdCopy(writer, writer, readCloser)
|
_, err = stdcopy.StdCopy(stdoutWriter, stderrWriter, readCloser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -150,3 +159,137 @@ func (gui *Gui) writeContainerLogs(ctr *commands.Container, ctx context.Context,
|
||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,23 @@ func (gui *Gui) switchFocusAux(newView *gocui.View) error {
|
||||||
|
|
||||||
gui.g.Cursor = newView.Editable
|
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 {
|
if err := gui.renderPanelOptions(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package gui
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
"github.com/jesseduffield/gocui"
|
"github.com/jesseduffield/gocui"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -74,6 +75,7 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
|
||||||
Modifier: gocui.ModNone,
|
Modifier: gocui.ModNone,
|
||||||
Handler: gui.quit,
|
Handler: gui.quit,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
ViewName: "",
|
ViewName: "",
|
||||||
Key: gocui.KeyPgup,
|
Key: gocui.KeyPgup,
|
||||||
|
|
@ -234,6 +236,13 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
|
||||||
Handler: gui.handleContainerViewLogs,
|
Handler: gui.handleContainerViewLogs,
|
||||||
Description: gui.Tr.ViewLogs,
|
Description: gui.Tr.ViewLogs,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ViewName: "containers",
|
||||||
|
Key: 'l',
|
||||||
|
Modifier: gocui.ModNone,
|
||||||
|
Handler: gui.handleContainerViewLogsExternal,
|
||||||
|
Description: "View logs with external pager",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ViewName: "containers",
|
ViewName: "containers",
|
||||||
Key: 'E',
|
Key: 'E',
|
||||||
|
|
@ -318,6 +327,13 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
|
||||||
Handler: gui.handleServiceRenderLogsToMain,
|
Handler: gui.handleServiceRenderLogsToMain,
|
||||||
Description: gui.Tr.ViewLogs,
|
Description: gui.Tr.ViewLogs,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ViewName: "services",
|
||||||
|
Key: 'l',
|
||||||
|
Modifier: gocui.ModNone,
|
||||||
|
Handler: gui.handleServiceViewLogsExternal,
|
||||||
|
Description: "View logs with external pager",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ViewName: "services",
|
ViewName: "services",
|
||||||
Key: 'U',
|
Key: 'U',
|
||||||
|
|
@ -437,6 +453,13 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
|
||||||
Handler: gui.handleExitMain,
|
Handler: gui.handleExitMain,
|
||||||
Description: gui.Tr.Return,
|
Description: gui.Tr.Return,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ViewName: "main",
|
||||||
|
Key: 'l',
|
||||||
|
Modifier: gocui.ModNone,
|
||||||
|
Handler: gui.handleMainViewLogsExternal,
|
||||||
|
Description: "View logs with external pager",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ViewName: "main",
|
ViewName: "main",
|
||||||
Key: gocui.KeyArrowLeft,
|
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 {
|
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()
|
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...)
|
||||||
|
}
|
||||||
|
|
|
||||||
75
pkg/gui/keybindings_test.go
Normal file
75
pkg/gui/keybindings_test.go
Normal 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'")
|
||||||
|
}
|
||||||
|
|
@ -333,6 +333,17 @@ func (gui *Gui) handleServiceRenderLogsToMain(g *gocui.Gui, v *gocui.View) error
|
||||||
return gui.runSubprocess(c)
|
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 {
|
func (gui *Gui) handleProjectUp(g *gocui.Gui, v *gocui.View) error {
|
||||||
project, _ := gui.Panels.Projects.GetSelectedItem()
|
project, _ := gui.Panels.Projects.GetSelectedItem()
|
||||||
if project != nil && project.Name != gui.DockerCommand.LocalProjectName {
|
if project != nil && project.Name != gui.DockerCommand.LocalProjectName {
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,13 @@ func (gui *Gui) setInitialViewContent() error {
|
||||||
|
|
||||||
func (gui *Gui) getInformationContent() string {
|
func (gui *Gui) getInformationContent() string {
|
||||||
informationStr := gui.Config.Version
|
informationStr := gui.Config.Version
|
||||||
|
|
||||||
|
mouseStatus := "Mouse: ON"
|
||||||
|
if !gui.g.Mouse {
|
||||||
|
mouseStatus = "Mouse: OFF (Drag to copy)"
|
||||||
|
}
|
||||||
|
informationStr = mouseStatus + " | " + informationStr
|
||||||
|
|
||||||
if !gui.g.Mouse {
|
if !gui.g.Mouse {
|
||||||
return informationStr
|
return informationStr
|
||||||
}
|
}
|
||||||
|
|
|
||||||
41
pkg/utils/logs.go
Normal file
41
pkg/utils/logs.go
Normal 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
41
pkg/utils/logs_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue