Files
oxkerclone/pkg/app/app.go
T

3534 lines
90 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"bufio"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
tea "charm.land/bubbletea/v2"
"image/color"
"charm.land/lipgloss/v2"
"github.com/creack/pty"
"github.com/docker/docker/api/types/container"
"github.com/hinshun/vt10x"
"github.com/oxker/oxker/pkg/config"
"github.com/oxker/oxker/pkg/docker"
)
// ============================================================
// TYPES
// ============================================================
type AppState int
const (
StateLoading AppState = iota
StateReady
StateError
)
type Panel int
const (
PanelContainers Panel = iota
PanelLogs
PanelCommands
)
type ContainerState int
const (
RunningHealthy ContainerState = iota
RunningUnhealthy
Paused
Exited
Dead
Restarting
Removing
Created
Unknown
)
type SortOrder int
const (
SortNone SortOrder = iota
SortAsc
SortDesc
)
type SortColumn int
const (
SortName SortColumn = iota
SortState
SortStatus
SortCPU
SortMemory
SortID
SortImage
SortRX
SortTX
)
type FilterBy int
const (
FilterByName FilterBy = iota
FilterByImage
FilterByStatus
FilterByAll
)
func (f FilterBy) String() string {
return [...]string{"Name", "Image", "Status", "All"}[f]
}
type ContainerPorts struct {
IP string
Private int
Public int
}
type Container struct {
ID string
Name string
Image string
State ContainerState
StateStr string
Status string
Ports []ContainerPorts
CreatedAt int64
CPUPercent float64
MemUsage uint64
MemLimit uint64
RxBytes uint64
TxBytes uint64
CPUHist []float64
MemHist []uint64
RxHist []uint64
TxHist []uint64
// Per-container log storage
Logs []string
LogScroll int
LogHScroll int
LogsSince string // timestamp for incremental fetching
}
// ============================================================
// MESSAGES
// ============================================================
type ContainersLoadedMsg struct{ Containers []Container }
type StatsUpdatedMsg struct {
ID string
CPU float64
MemUsage uint64
MemLimit uint64
Rx, Tx uint64
}
type ContainerActionMsg struct{ Action, ID string; Err error }
type ErrorMsg struct{ Err error }
type TickMsg struct{}
type LogsLoadedMsg struct{ ID string; Lines []string; Since string }
type InspectLoadedMsg struct{ Data string }
type InfoDismissMsg struct{}
type ExecDoneMsg struct{ Err error }
type ConnectCountdownMsg struct{ SecsLeft int }
type ExecOutputMsg struct{} // tick to refresh VTE display
type ExecExitMsg struct{ Err error }
// ============================================================
// APP MODEL
// ============================================================
type App struct {
// State
State AppState
CmdMgr *docker.Client
Config *config.Config
Width int
Height int
Error error
// Containers
Containers []Container
SelectedIdx int
SelectedID string // persistent selected container ID
ScrollOffset int
PrevSelectedID string // track selection changes for log save/restore
// Panels
ActivePanel Panel
// Logs
LogLines []string
LogScroll int
LogHScroll int
LogHeight int // percentage 5-80
ShowLogs bool
// Sorting
SortCol SortColumn
SortOrd SortOrder
// Filtering
FilterMode bool
FilterText string
FilterBy FilterBy
// Log Search
SearchMode bool
SearchText string
SearchMatches []int
SearchIdx int
// Inspect
InspectMode bool
InspectData string
InspectScrollY int
InspectScrollX int
// Delete confirm
DeleteConfirm bool
DeleteTarget string
// Help
ShowHelp bool
// Command selection
CmdSelectedIdx int
// Stats dedup — prevent duplicate in-flight stats requests
PendingStats map[string]bool
// Info box (bottom-right, auto-dismiss)
InfoText string
InfoTimeout *time.Timer
// Loading animation
LoadingIdx int
// Mouse capture
MouseEnabled bool
// Docker connect error countdown (auto-exit)
ConnectCountdown int
// Embedded exec terminal (PTY + VTE)
ExecMode bool
ExecPTY *os.File // PTY master fd
ExecCmd *exec.Cmd // running docker exec process
ExecVT vt10x.Terminal // virtual terminal emulator
ExecMu sync.Mutex // protects ExecVT
ExecDone chan struct{} // closed when exec process exits
ExecContainer string // container name
ExecImage string // container image
ExecID string // container ID short
// Exec mouse selection
ExecSelecting bool // drag in progress
ExecSelActive bool // selection exists (show highlight)
ExecSelStartR int // start row (in visible area, 0-based)
ExecSelStartC int // start col
ExecSelEndR int // end row
ExecSelEndC int // end col
}
func New(cfg ...*config.Config) *App {
var c *config.Config
if len(cfg) > 0 && cfg[0] != nil {
c = cfg[0]
} else {
c = config.NewConfig()
}
cli, err := docker.New(c.Host)
app := &App{
State: StateLoading,
Config: c,
LogHeight: 75,
ShowLogs: c.ShowLogs,
MouseEnabled: true,
}
if err != nil {
app.Error = fmt.Errorf("Docker connect failed: %v", err)
app.ConnectCountdown = 5
} else if pingErr := cli.Ping(); pingErr != nil {
host := c.Host
if host == "" { host = "default socket" }
app.Error = fmt.Errorf("Cannot connect to Docker at %s: %v", host, pingErr)
app.ConnectCountdown = 5
} else {
app.CmdMgr = cli
}
return app
}
func (a *App) Init() tea.Cmd {
if a.ConnectCountdown > 0 {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return ConnectCountdownMsg{SecsLeft: a.ConnectCountdown}
})
}
return tea.Batch(a.loadContainers(), a.tickCmd())
}
func (a *App) tickCmd() tea.Cmd {
return tea.Tick(time.Duration(a.Config.DockerIntervalMs)*time.Millisecond, func(t time.Time) tea.Msg {
return TickMsg{}
})
}
// quit cancels in-flight Docker calls and returns tea.Quit.
func (a *App) quit() (*App, tea.Cmd) {
if a.CmdMgr != nil {
a.CmdMgr.Cancel()
}
return a, tea.Quit
}
// ============================================================
// UPDATE
// ============================================================
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Exec mode: route most messages to exec handler
if a.ExecMode {
return a.updateExecMode(msg)
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
a.Width = msg.Width
a.Height = msg.Height
return a, nil
case tea.KeyPressMsg:
return a.handleKey(msg)
case tea.MouseClickMsg:
return a.handleMouseClick(msg)
case tea.MouseWheelMsg:
return a.handleMouseWheel(msg)
case tea.MouseMotionMsg:
// No-op in normal mode
return a, nil
case tea.MouseReleaseMsg:
// No-op in normal mode
return a, nil
case ContainersLoadedMsg:
return a.handleContainersLoaded(msg)
case StatsUpdatedMsg:
return a.handleStatsUpdated(msg)
case LogsLoadedMsg:
for i := range a.Containers {
if a.Containers[i].ID == msg.ID {
c := &a.Containers[i]
if msg.Since != "" && len(c.Logs) > 0 {
// Incremental: append new lines, dedup
existing := make(map[string]bool, len(c.Logs))
for _, l := range c.Logs {
existing[l] = true
}
for _, l := range msg.Lines {
if !existing[l] {
c.Logs = append(c.Logs, l)
}
}
} else {
c.Logs = msg.Lines
}
c.LogsSince = time.Now().UTC().Format(time.RFC3339Nano)
break
}
}
// Sync to active view if this is the selected container
if sel := a.selected(); sel != nil && sel.ID == msg.ID {
// Auto-follow: if we were at/near the bottom, scroll to new bottom
wasAtBottom := a.LogScroll >= len(a.LogLines) - a.logsVisibleRows() - 2
a.LogLines = sel.Logs
if wasAtBottom || len(a.LogLines) <= a.logsVisibleRows() {
max := len(a.LogLines) - a.logsVisibleRows()
if max < 0 { max = 0 }
a.LogScroll = max
}
}
return a, nil
case InspectLoadedMsg:
a.InspectData = msg.Data
a.InspectMode = true
a.InspectScrollX = 0
a.InspectScrollY = 0
return a, nil
case ContainerActionMsg:
if msg.Err != nil {
a.Error = msg.Err
} else {
dismissCmd := a.setInfo(fmt.Sprintf("%s: success", msg.Action))
return a, tea.Batch(a.loadContainers(), dismissCmd)
}
return a, a.loadContainers()
case ErrorMsg:
a.Error = msg.Err
a.State = StateError
return a, nil
case TickMsg:
a.LoadingIdx = (a.LoadingIdx + 1) % 10
cmds := []tea.Cmd{a.tickCmd(), a.loadContainers()}
// Update stats for all running containers
cmds = append(cmds, a.updateAllStats()...)
return a, tea.Batch(cmds...)
case InfoDismissMsg:
a.InfoText = ""
return a, nil
case ExecDoneMsg:
if msg.Err != nil {
a.Error = msg.Err
}
return a, a.loadContainers()
case ConnectCountdownMsg:
a.ConnectCountdown = msg.SecsLeft - 1
if a.ConnectCountdown <= 0 {
return a.quit()
}
return a, tea.Tick(time.Second, func(t time.Time) tea.Msg {
return ConnectCountdownMsg{SecsLeft: a.ConnectCountdown}
})
}
return a, nil
}
func (a *App) handleContainersLoaded(msg ContainersLoadedMsg) (*App, tea.Cmd) {
// Merge stats from existing containers
oldMap := make(map[string]*Container)
for i := range a.Containers {
oldMap[a.Containers[i].ID] = &a.Containers[i]
}
for i := range msg.Containers {
if old, ok := oldMap[msg.Containers[i].ID]; ok {
msg.Containers[i].CPUPercent = old.CPUPercent
msg.Containers[i].MemUsage = old.MemUsage
msg.Containers[i].MemLimit = old.MemLimit
msg.Containers[i].RxBytes = old.RxBytes
msg.Containers[i].TxBytes = old.TxBytes
msg.Containers[i].CPUHist = old.CPUHist
msg.Containers[i].MemHist = old.MemHist
msg.Containers[i].RxHist = old.RxHist
msg.Containers[i].TxHist = old.TxHist
// Preserve per-container logs
msg.Containers[i].Logs = old.Logs
msg.Containers[i].LogScroll = old.LogScroll
msg.Containers[i].LogHScroll = old.LogHScroll
msg.Containers[i].LogsSince = old.LogsSince
}
}
a.Containers = msg.Containers
a.State = StateReady
// Use persistent SelectedID to restore selection after sort
a.sortContainersWithID(a.SelectedID)
return a, a.loadLogsForSelected()
}
func (a *App) handleStatsUpdated(msg StatsUpdatedMsg) (*App, tea.Cmd) {
delete(a.PendingStats, msg.ID)
for i := range a.Containers {
if a.Containers[i].ID == msg.ID {
c := &a.Containers[i]
c.CPUPercent = msg.CPU
c.MemUsage = msg.MemUsage
c.MemLimit = msg.MemLimit
c.RxBytes = msg.Rx
c.TxBytes = msg.Tx
// History (max 60 samples)
c.CPUHist = appendMax(c.CPUHist, msg.CPU, 60)
c.MemHist = appendMaxU(c.MemHist, msg.MemUsage, 60)
c.RxHist = appendMaxU(c.RxHist, msg.Rx, 60)
c.TxHist = appendMaxU(c.TxHist, msg.Tx, 60)
break
}
}
return a, nil
}
// ============================================================
// KEY HANDLING
// ============================================================
func (a *App) handleKey(msg tea.KeyPressMsg) (*App, tea.Cmd) {
// Ctrl+C always quits regardless of mode
if msg.String() == "ctrl+c" {
return a.quit()
}
// Inspect mode - separate handling
if a.InspectMode {
return a.handleInspectKey(msg)
}
// Delete confirm
if a.DeleteConfirm {
return a.handleDeleteConfirmKey(msg)
}
// Error mode - only allow clear/quit
if a.Error != nil {
k := msg.String()
if keyMatch(k, a.Config.Keymap.Clear) || k == "c" || k == "esc" {
a.Error = nil
a.ConnectCountdown = 0
if a.State == StateError {
a.State = StateReady
}
} else if keyMatch(k, a.Config.Keymap.Quit) || k == "q" {
return a.quit()
}
return a, nil
}
// Filter mode
if a.FilterMode {
return a.handleFilterKey(msg)
}
// Search mode
if a.SearchMode {
return a.handleSearchKey(msg)
}
// Help overlay
if a.ShowHelp {
switch msg.String() {
case "h", "esc", "q":
a.ShowHelp = false
}
return a, nil
}
key := msg.String()
km := &a.Config.Keymap
// Keymap-aware dispatch: check config keybindings first, fall back to defaults
switch {
case keyMatch(key, km.Quit) || key == "q":
return a.quit()
// Panel navigation
case keyMatch(key, km.SelectNextPanel) || key == "tab":
a.nextPanel()
case keyMatch(key, km.SelectPreviousPanel) || key == "shift+tab":
a.prevPanel()
// Scroll
case keyMatch(key, km.ScrollDown) || key == "j" || key == "down":
a.scrollDown(1)
return a, a.loadLogsForSelected()
case keyMatch(key, km.ScrollUp) || key == "k" || key == "up":
a.scrollUp(1)
return a, a.loadLogsForSelected()
case key == "J":
a.scrollDown(10)
return a, a.loadLogsForSelected()
case key == "K":
a.scrollUp(10)
return a, a.loadLogsForSelected()
case keyMatch(key, km.ScrollStart) || key == "g" || key == "home":
a.scrollHome()
return a, a.loadLogsForSelected()
case keyMatch(key, km.ScrollEnd) || key == "G" || key == "end":
a.scrollEnd()
return a, a.loadLogsForSelected()
case keyMatch(key, km.ScrollForward) || key == "pgdown":
a.scrollDown(a.visibleRows())
return a, a.loadLogsForSelected()
case keyMatch(key, km.ScrollBack) || key == "pgup":
a.scrollUp(a.visibleRows())
return a, a.loadLogsForSelected()
// Sorting
case keyMatch(key, km.SortByName) || key == "1":
a.toggleSort(SortName)
case keyMatch(key, km.SortByState) || key == "2":
a.toggleSort(SortState)
case keyMatch(key, km.SortByStatus) || key == "3":
a.toggleSort(SortStatus)
case keyMatch(key, km.SortByCPU) || key == "4":
a.toggleSort(SortCPU)
case keyMatch(key, km.SortByMemory) || key == "5":
a.toggleSort(SortMemory)
case keyMatch(key, km.SortByID) || key == "6":
a.toggleSort(SortID)
case keyMatch(key, km.SortByImage) || key == "7":
a.toggleSort(SortImage)
case keyMatch(key, km.SortByRX) || key == "8":
a.toggleSort(SortRX)
case keyMatch(key, km.SortByTX) || key == "9":
a.toggleSort(SortTX)
case keyMatch(key, km.SortReset) || key == "0":
a.SortOrd = SortNone
a.sortContainers()
// Filter
case keyMatch(key, km.FilterMode) || key == "/":
a.FilterMode = true
a.FilterText = ""
// Log search
case keyMatch(key, km.LogSearchMode) || key == "#":
a.SearchMode = true
a.SearchText = ""
a.SearchMatches = nil
// Log panel controls
case keyMatch(key, km.LogSectionToggle) || key == "l":
a.ShowLogs = !a.ShowLogs
case keyMatch(key, km.LogSectionHeightIncrease) || key == "]":
if a.LogHeight < 80 {
a.LogHeight += 5
}
case keyMatch(key, km.LogSectionHeightDecrease) || key == "[":
if a.LogHeight > 5 {
a.LogHeight -= 5
}
// Log horizontal scroll
case key == "right":
if a.ActivePanel == PanelLogs {
a.LogHScroll += 10
}
case key == "left":
if a.ActivePanel == PanelLogs {
a.LogHScroll -= 10
if a.LogHScroll < 0 {
a.LogHScroll = 0
}
}
// Container actions
case key == "enter":
return a, a.execSelectedCommand()
case key == "s":
return a, a.doAction((*docker.Client).StartContainer, "start")
case key == "x":
return a, a.doAction((*docker.Client).StopContainer, "stop")
case key == "r":
return a, a.doAction((*docker.Client).RestartContainer, "restart")
case key == "d":
a.confirmDelete()
case key == "p":
return a, a.doAction((*docker.Client).PauseContainer, "pause")
case key == "u":
return a, a.doAction((*docker.Client).UnpauseContainer, "unpause")
// Docker exec
case keyMatch(key, km.Exec) || key == "e":
return a, a.execIntoContainer()
// Inspect
case keyMatch(key, km.Inspect) || key == "i":
return a, a.inspectSelected()
// Save logs
case keyMatch(key, km.SaveLogs) || key == "ctrl+s" || key == "S":
return a, a.saveLogs()
// Help
case keyMatch(key, km.ToggleHelp) || key == "h":
a.ShowHelp = true
// Force redraw
case keyMatch(key, km.ForceRedraw) || key == "f":
return a, tea.ClearScreen
// Toggle mouse capture (controlled via View return)
case keyMatch(key, km.ToggleMouseCapture) || key == "m":
a.MouseEnabled = !a.MouseEnabled
// Clear
case keyMatch(key, km.Clear) || key == "esc":
a.ShowHelp = false
}
return a, nil
}
func (a *App) handleInspectKey(msg tea.KeyPressMsg) (*App, tea.Cmd) {
switch msg.String() {
case "esc", "i", "q":
a.InspectMode = false
case "j", "down":
a.InspectScrollY++
a.clampInspectScroll()
case "k", "up":
if a.InspectScrollY > 0 {
a.InspectScrollY--
}
case "l", "right":
a.InspectScrollX += 5
case "h", "left":
a.InspectScrollX -= 5
if a.InspectScrollX < 0 {
a.InspectScrollX = 0
}
case "g", "home":
a.InspectScrollY = 0
a.InspectScrollX = 0
case "G", "end":
lines := strings.Count(a.InspectData, "\n")
a.InspectScrollY = lines
a.clampInspectScroll()
}
return a, nil
}
func (a *App) handleDeleteConfirmKey(msg tea.KeyPressMsg) (*App, tea.Cmd) {
k := msg.String()
km := &a.Config.Keymap
switch {
case keyMatch(k, km.DeleteConfirm) || k == "y" || k == "enter":
a.DeleteConfirm = false
id := a.DeleteTarget
if id == "" || a.CmdMgr == nil {
return a, nil
}
return a, func() tea.Msg {
err := a.CmdMgr.DeleteContainer(id, true)
return ContainerActionMsg{Action: "delete", ID: id, Err: err}
}
case keyMatch(k, km.DeleteDeny) || k == "n" || k == "esc":
a.DeleteConfirm = false
a.DeleteTarget = ""
}
return a, nil
}
func (a *App) handleFilterKey(msg tea.KeyPressMsg) (*App, tea.Cmd) {
switch msg.String() {
case "esc":
a.FilterMode = false
a.FilterText = ""
case "enter", "/":
a.FilterMode = false
case "backspace":
if len(a.FilterText) > 0 {
a.FilterText = a.FilterText[:len(a.FilterText)-1]
}
case "ctrl+u":
a.FilterText = ""
case "left":
if a.FilterBy > 0 {
a.FilterBy--
}
case "right":
if a.FilterBy < FilterByAll {
a.FilterBy++
}
default:
if msg.Text != "" {
a.FilterText += msg.Text
}
}
return a, nil
}
func (a *App) handleSearchKey(msg tea.KeyPressMsg) (*App, tea.Cmd) {
switch msg.String() {
case "esc":
a.SearchMode = false
a.SearchText = ""
a.SearchMatches = nil
case "enter", "#":
a.SearchMode = false
case "backspace":
if len(a.SearchText) > 0 {
a.SearchText = a.SearchText[:len(a.SearchText)-1]
a.updateSearchMatches()
}
case "ctrl+n", "down":
if len(a.SearchMatches) > 0 {
a.SearchIdx = (a.SearchIdx + 1) % len(a.SearchMatches)
a.LogScroll = a.SearchMatches[a.SearchIdx]
}
case "ctrl+p", "up":
if len(a.SearchMatches) > 0 {
a.SearchIdx--
if a.SearchIdx < 0 {
a.SearchIdx = len(a.SearchMatches) - 1
}
a.LogScroll = a.SearchMatches[a.SearchIdx]
}
case "right":
a.LogHScroll += 10
case "left":
a.LogHScroll -= 10
if a.LogHScroll < 0 { a.LogHScroll = 0 }
default:
if msg.Text != "" {
a.SearchText += msg.Text
a.updateSearchMatches()
}
}
return a, nil
}
// ============================================================
// MOUSE HANDLING
// ============================================================
func (a *App) handleMouseWheel(msg tea.MouseWheelMsg) (*App, tea.Cmd) {
// Block mouse in overlay modes
if a.DeleteConfirm || a.ShowHelp || a.FilterMode || a.SearchMode || a.Error != nil {
return a, nil
}
// Inspect mode: route scroll to inspect panel
if a.InspectMode {
if msg.Button == tea.MouseWheelUp {
if a.InspectScrollY > 0 {
a.InspectScrollY--
}
} else if msg.Button == tea.MouseWheelDown {
a.InspectScrollY++
}
return a, nil
}
if msg.Button == tea.MouseWheelUp {
a.scrollUp(1)
return a, a.loadLogsForSelected()
} else if msg.Button == tea.MouseWheelDown {
a.scrollDown(1)
return a, a.loadLogsForSelected()
}
return a, nil
}
func (a *App) handleMouseClick(msg tea.MouseClickMsg) (*App, tea.Cmd) {
// Block mouse in overlay modes
if a.DeleteConfirm || a.ShowHelp || a.FilterMode || a.SearchMode || a.Error != nil {
return a, nil
}
if a.InspectMode {
return a, nil
}
x, y := msg.X, msg.Y
containerH := a.containerAreaHeight()
cmdW := a.Width * 10 / 100
if cmdW < 12 { cmdW = 12 }
tableW := a.Width - cmdW
// Container section (y=0..containerH-1)
if y < containerH {
if x < tableW {
// Click in container table area
a.ActivePanel = PanelContainers
if y == 0 {
// Top border = sort by column header
col := a.headerColumnAt(x)
if col >= 0 {
a.toggleSort(SortColumn(col))
}
return a, nil
}
// Content rows: y=1..containerH-2
row := y - 1 + a.ScrollOffset
if row >= 0 && row < len(a.filtered()) {
a.SelectedIdx = row
a.CmdSelectedIdx = 0
a.syncSelectedID()
a.ensureVisible()
return a, a.loadLogsForSelected()
}
} else {
// Click in commands panel
a.ActivePanel = PanelCommands
// Map click Y to command index.
// Commands box: y=0 is top border, y=1..innerH is content.
// Content is vertically centered with blank lines between commands.
c := a.selected()
if c != nil {
cmds := commandsForState(c.State)
innerH := containerH - 2
// visual lines = 2*len(cmds)-1 (cmd + blank between each)
visualCount := 2*len(cmds) - 1
if visualCount < 1 { visualCount = 1 }
topPad := (innerH - visualCount) / 2
if topPad < 0 { topPad = 0 }
contentY := y - 1 // subtract top border
lineIdx := contentY - topPad
// Even indices (0,2,4,6) are commands, odd are blank lines
if lineIdx >= 0 && lineIdx%2 == 0 {
cmdIdx := lineIdx / 2
if cmdIdx >= 0 && cmdIdx < len(cmds) {
a.CmdSelectedIdx = cmdIdx
}
}
}
}
return a, nil
}
// Logs area
logsStart := containerH
logsEnd := logsStart + a.logsAreaHeight()
if y >= logsStart && y < logsEnd {
a.ActivePanel = PanelLogs
return a, nil
}
return a, nil
}
func (a *App) headerColumnAt(x int) int {
cw := a.colWidths()
cols := []int{3, cw.name, cw.state, cw.status, cw.cpu, cw.mem, cw.id, cw.image, cw.rx, cw.tx}
pos := 0
for i, w := range cols {
pos += w
if x < pos {
if i == 0 {
return -1 // indicator column
}
return i - 1 // SortColumn index
}
}
return -1
}
// HandleClick processes a mouse click at the given app-relative coordinates.
// Used by the embedding overlay to forward translated mouse events.
func (a *App) HandleClick(x, y int) tea.Cmd {
if a.DeleteConfirm || a.ShowHelp || a.FilterMode || a.SearchMode || a.Error != nil {
return nil
}
if a.InspectMode {
return nil
}
containerH := a.containerAreaHeight()
cmdW := a.Width * 10 / 100
if cmdW < 12 { cmdW = 12 }
tableW := a.Width - cmdW
// Container section (y=0..containerH-1)
if y < containerH {
if x < tableW {
a.ActivePanel = PanelContainers
if y == 0 {
col := a.headerColumnAt(x)
if col >= 0 {
a.toggleSort(SortColumn(col))
}
return nil
}
row := y - 1 + a.ScrollOffset
if row >= 0 && row < len(a.filtered()) {
a.SelectedIdx = row
a.CmdSelectedIdx = 0
a.syncSelectedID()
a.ensureVisible()
return a.loadLogsForSelected()
}
} else {
a.ActivePanel = PanelCommands
sel := a.selected()
if sel == nil {
return nil
}
cmds := commandsForState(sel.State)
innerH := containerH - 2
visualCount := 2*len(cmds) - 1
if visualCount < 1 { visualCount = 1 }
topPad := (innerH - visualCount) / 2
if topPad < 0 { topPad = 0 }
contentY := y - 1
lineIdx := contentY - topPad
if lineIdx >= 0 && lineIdx%2 == 0 {
cmdIdx := lineIdx / 2
if cmdIdx >= 0 && cmdIdx < len(cmds) {
a.CmdSelectedIdx = cmdIdx
}
}
}
return nil
}
// Logs area
logsStart := containerH
logsEnd := logsStart + a.logsAreaHeight()
if y >= logsStart && y < logsEnd {
a.ActivePanel = PanelLogs
return nil
}
return nil
}
// ============================================================
// NAVIGATION & SCROLL
// ============================================================
func (a *App) nextPanel() {
if a.ShowLogs {
a.ActivePanel = Panel((int(a.ActivePanel) + 1) % 3)
} else {
if a.ActivePanel == PanelContainers {
a.ActivePanel = PanelCommands
} else {
a.ActivePanel = PanelContainers
}
}
}
func (a *App) prevPanel() {
if a.ShowLogs {
p := int(a.ActivePanel) - 1
if p < 0 {
p = 2
}
a.ActivePanel = Panel(p)
} else {
if a.ActivePanel == PanelContainers {
a.ActivePanel = PanelCommands
} else {
a.ActivePanel = PanelContainers
}
}
}
func (a *App) scrollDown(n int) {
if a.ActivePanel == PanelCommands {
c := a.selected()
if c == nil { return }
cmds := commandsForState(c.State)
a.CmdSelectedIdx += n
if a.CmdSelectedIdx >= len(cmds) {
a.CmdSelectedIdx = len(cmds) - 1
}
if a.CmdSelectedIdx < 0 { a.CmdSelectedIdx = 0 }
return
}
if a.ActivePanel == PanelLogs {
a.LogScroll += n
max := len(a.LogLines) - a.logsVisibleRows()
if max < 0 { max = 0 }
if a.LogScroll > max { a.LogScroll = max }
} else {
cs := a.filtered()
a.SelectedIdx += n
if a.SelectedIdx >= len(cs) {
a.SelectedIdx = len(cs) - 1
}
if a.SelectedIdx < 0 { a.SelectedIdx = 0 }
a.CmdSelectedIdx = 0
a.syncSelectedID()
a.ensureVisible()
}
}
func (a *App) scrollUp(n int) {
if a.ActivePanel == PanelCommands {
a.CmdSelectedIdx -= n
if a.CmdSelectedIdx < 0 { a.CmdSelectedIdx = 0 }
return
}
if a.ActivePanel == PanelLogs {
a.LogScroll -= n
if a.LogScroll < 0 { a.LogScroll = 0 }
} else {
a.SelectedIdx -= n
if a.SelectedIdx < 0 { a.SelectedIdx = 0 }
a.CmdSelectedIdx = 0
a.syncSelectedID()
a.ensureVisible()
}
}
func (a *App) scrollHome() {
if a.ActivePanel == PanelLogs {
a.LogScroll = 0
} else {
a.SelectedIdx = 0
a.ScrollOffset = 0
a.syncSelectedID()
}
}
func (a *App) scrollEnd() {
if a.ActivePanel == PanelLogs {
max := len(a.LogLines) - a.logsVisibleRows()
if max < 0 { max = 0 }
a.LogScroll = max
} else {
cs := a.filtered()
if len(cs) > 0 {
a.SelectedIdx = len(cs) - 1
a.syncSelectedID()
a.ensureVisible()
}
}
}
func (a *App) ensureVisible() {
vis := a.containerVisibleRows()
if a.SelectedIdx < a.ScrollOffset {
a.ScrollOffset = a.SelectedIdx
}
if a.SelectedIdx >= a.ScrollOffset+vis {
a.ScrollOffset = a.SelectedIdx - vis + 1
}
}
func (a *App) visibleRows() int {
return a.containerVisibleRows()
}
func (a *App) clampInspectScroll() {
total := strings.Count(a.InspectData, "\n") + 1
vis := a.Height - 4
if vis < 1 { vis = 1 }
max := total - vis
if max < 0 { max = 0 }
if a.InspectScrollY > max { a.InspectScrollY = max }
if a.InspectScrollY < 0 { a.InspectScrollY = 0 }
}
// ============================================================
// SORTING
// ============================================================
func (a *App) toggleSort(col SortColumn) {
if a.SortCol == col {
switch a.SortOrd {
case SortNone:
a.SortOrd = SortAsc
case SortAsc:
a.SortOrd = SortDesc
case SortDesc:
a.SortOrd = SortNone
}
} else {
a.SortCol = col
a.SortOrd = SortAsc
}
a.sortContainers()
}
func (a *App) sortContainers() {
var selectedID string
if sel := a.selected(); sel != nil {
selectedID = sel.ID
}
a.sortContainersWithID(selectedID)
}
func (a *App) sortContainersWithID(selectedID string) {
cs := a.Containers
less := func(i, j int) bool {
var cmp int
if a.SortOrd == SortNone {
// Default sort: by CreatedAt, Name as tiebreaker
cmp = compareInt64(cs[i].CreatedAt, cs[j].CreatedAt)
} else {
switch a.SortCol {
case SortName:
cmp = strings.Compare(strings.ToLower(cs[i].Name), strings.ToLower(cs[j].Name))
case SortState:
cmp = int(stateOrder(cs[i].State)) - int(stateOrder(cs[j].State))
case SortStatus:
cmp = strings.Compare(cs[i].Status, cs[j].Status)
case SortCPU:
cmp = compareFloat(cs[i].CPUPercent, cs[j].CPUPercent)
case SortMemory:
cmp = compareUint(cs[i].MemUsage, cs[j].MemUsage)
case SortID:
cmp = strings.Compare(cs[i].ID, cs[j].ID)
case SortImage:
cmp = strings.Compare(strings.ToLower(cs[i].Image), strings.ToLower(cs[j].Image))
case SortRX:
cmp = compareUint(cs[i].RxBytes, cs[j].RxBytes)
case SortTX:
cmp = compareUint(cs[i].TxBytes, cs[j].TxBytes)
}
}
// Tiebreaker: sort by name
if cmp == 0 {
cmp = strings.Compare(strings.ToLower(cs[i].Name), strings.ToLower(cs[j].Name))
}
if a.SortOrd == SortDesc {
return cmp > 0
}
return cmp < 0
}
// Simple insertion sort (few items, stable)
for i := 1; i < len(cs); i++ {
for j := i; j > 0 && less(j, j-1); j-- {
cs[j], cs[j-1] = cs[j-1], cs[j]
}
}
// Restore selection by ID (H8)
if selectedID != "" {
for i, c := range cs {
if c.ID == selectedID {
a.SelectedIdx = i
break
}
}
}
// Clamp and sync SelectedID
if a.SelectedIdx >= len(cs) {
a.SelectedIdx = len(cs) - 1
}
if a.SelectedIdx < 0 {
a.SelectedIdx = 0
}
a.syncSelectedID()
}
// stateOrder returns semantic sort order matching Rust (H10)
func stateOrder(s ContainerState) int {
switch s {
case RunningHealthy: return 0
case RunningUnhealthy: return 1
case Paused: return 2
case Restarting: return 3
case Removing: return 4
case Exited: return 5
case Dead: return 6
case Created: return 7
case Unknown: return 8
default: return 9
}
}
func compareInt64(a, b int64) int {
if a < b { return -1 }
if a > b { return 1 }
return 0
}
// ============================================================
// SEARCH
// ============================================================
func (a *App) updateSearchMatches() {
a.SearchMatches = nil
a.SearchIdx = 0
if a.SearchText == "" {
return
}
caseSensitive := a.Config != nil && a.Config.LogSearchCaseSensitive
term := a.SearchText
if !caseSensitive {
term = strings.ToLower(term)
}
for i, line := range a.LogLines {
haystack := line
if !caseSensitive {
haystack = strings.ToLower(haystack)
}
if strings.Contains(haystack, term) {
a.SearchMatches = append(a.SearchMatches, i)
}
}
}
// ============================================================
// CONTAINER ACTIONS
// ============================================================
func (a *App) selected() *Container {
cs := a.filtered()
if a.SelectedIdx >= 0 && a.SelectedIdx < len(cs) {
id := cs[a.SelectedIdx].ID
for i := range a.Containers {
if a.Containers[i].ID == id {
return &a.Containers[i]
}
}
}
return nil
}
// syncSelectedID updates SelectedID to match current SelectedIdx
func (a *App) syncSelectedID() {
cs := a.filtered()
if a.SelectedIdx >= 0 && a.SelectedIdx < len(cs) {
a.SelectedID = cs[a.SelectedIdx].ID
}
}
// syncLogSelection saves scroll state to old container and restores from new one
func (a *App) syncLogSelection() {
sel := a.selected()
newID := ""
if sel != nil { newID = sel.ID }
if newID == a.PrevSelectedID { return }
// Save scroll position to previous container
if a.PrevSelectedID != "" {
for i := range a.Containers {
if a.Containers[i].ID == a.PrevSelectedID {
a.Containers[i].LogScroll = a.LogScroll
a.Containers[i].LogHScroll = a.LogHScroll
break
}
}
}
// Restore from new container
if sel != nil {
a.LogLines = sel.Logs
a.LogScroll = sel.LogScroll
a.LogHScroll = sel.LogHScroll
} else {
a.LogLines = nil
a.LogScroll = 0
a.LogHScroll = 0
}
a.PrevSelectedID = newID
}
func (a *App) doAction(fn func(*docker.Client, string) error, action string) tea.Cmd {
c := a.selected()
if c == nil || a.CmdMgr == nil { return nil }
id := c.ID
mgr := a.CmdMgr
return func() tea.Msg {
err := fn(mgr, id)
return ContainerActionMsg{Action: action, ID: id, Err: err}
}
}
func (a *App) confirmDelete() {
c := a.selected()
if c == nil { return }
a.DeleteConfirm = true
a.DeleteTarget = c.ID
}
func (a *App) execSelectedCommand() tea.Cmd {
c := a.selected()
if c == nil { return nil }
cmds := commandsForState(c.State)
if len(cmds) == 0 { return nil }
idx := a.CmdSelectedIdx
if idx >= len(cmds) { idx = 0 }
cmd := cmds[idx]
switch cmd {
case "start":
return a.doAction((*docker.Client).StartContainer, "start")
case "stop":
return a.doAction((*docker.Client).StopContainer, "stop")
case "pause":
return a.doAction((*docker.Client).PauseContainer, "pause")
case "resume":
return a.doAction((*docker.Client).UnpauseContainer, "resume")
case "restart":
return a.doAction((*docker.Client).RestartContainer, "restart")
case "delete":
a.confirmDelete()
}
return nil
}
func (a *App) inspectSelected() tea.Cmd {
c := a.selected()
if c == nil || a.CmdMgr == nil { return nil }
id := c.ID
mgr := a.CmdMgr
return func() tea.Msg {
details, err := mgr.InspectContainer(id)
if err != nil {
return ErrorMsg{Err: err}
}
data, _ := json.MarshalIndent(details, "", " ")
return InspectLoadedMsg{Data: string(data)}
}
}
func (a *App) saveLogs() tea.Cmd {
c := a.selected()
if c == nil { return nil }
name := c.Name
lines := a.LogLines
saveDir := a.Config.DirSave
return func() tea.Msg {
filename := fmt.Sprintf("oxker_%s_%s.log", name, time.Now().Format("20060102_150405"))
if saveDir != "" {
filename = filepath.Join(saveDir, filename)
}
var sb strings.Builder
for _, l := range lines {
sb.WriteString(l + "\n")
}
if err := os.WriteFile(filename, []byte(sb.String()), 0644); err != nil {
return ErrorMsg{Err: err}
}
return ContainerActionMsg{Action: fmt.Sprintf("saved to %s", filename), Err: nil}
}
}
func (a *App) execIntoContainer() tea.Cmd {
c := a.selected()
if c == nil {
return nil
}
if c.State != RunningHealthy && c.State != RunningUnhealthy {
return a.setInfo("can only exec into running containers")
}
id := c.ID
shortID := id
if len(shortID) > 12 { shortID = shortID[:12] }
w, h := a.execPanelSize()
cmd := exec.Command("docker", "exec", "-it", id, "sh")
// Start with PTY
ptmx, err := pty.Start(cmd)
if err != nil {
return a.setInfo(fmt.Sprintf("exec failed: %v", err))
}
_ = pty.Setsize(ptmx, &pty.Winsize{Rows: uint16(h), Cols: uint16(w)})
// Create VTE with matching size
vt := vt10x.New(vt10x.WithSize(w, h), vt10x.WithWriter(ptmx))
a.ExecMode = true
a.ExecPTY = ptmx
a.ExecCmd = cmd
a.ExecVT = vt
a.ExecDone = make(chan struct{})
a.ExecContainer = c.Name
a.ExecImage = c.Image
a.ExecID = shortID
a.ExecSelActive = false
a.ExecSelecting = false
// Start VTE parser in background — it reads PTY and updates the screen buffer.
// When PTY closes (shell exits), signal via ExecDone.
done := a.ExecDone
go func() {
defer close(done)
br := bufio.NewReader(ptmx)
for {
if err := vt.Parse(br); err != nil {
return
}
}
// Also wait for the process to finish
}()
// Tick to refresh the exec view
return a.execTick()
}
func (a *App) execTick() tea.Cmd {
return tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg {
return ExecOutputMsg{}
})
}
func (a *App) execPanelSize() (w, h int) {
w = a.Width * 70 / 100
if w < 40 { w = 40 }
w -= 2 // subtract border columns
h = a.Height * 70 / 100
if h < 10 { h = 10 }
h -= 2 // subtract border rows (top + bottom)
return
}
func (a *App) execClose() {
if a.ExecPTY != nil {
a.ExecPTY.Close()
a.ExecPTY = nil
}
if a.ExecCmd != nil && a.ExecCmd.Process != nil {
a.ExecCmd.Process.Kill()
a.ExecCmd.Wait()
a.ExecCmd = nil
}
a.ExecVT = nil
a.ExecDone = nil
a.ExecMode = false
}
func (a *App) execWriteKey(msg tea.KeyPressMsg) {
if a.ExecPTY == nil { return }
var b []byte
// Check for ctrl+key combinations first
if msg.Mod&tea.ModCtrl != 0 {
switch msg.Code {
case 'c':
b = []byte{3}
case 'd':
b = []byte{4}
case 'z':
b = []byte{26}
case 'l':
b = []byte{12}
case 'a':
b = []byte{1}
case 'e':
b = []byte{5}
case 'u':
b = []byte{21}
case 'k':
b = []byte{11}
case 'w':
b = []byte{23}
}
} else {
switch msg.Code {
case tea.KeyEnter:
b = []byte{'\r'}
case tea.KeyTab:
b = []byte{'\t'}
case tea.KeyBackspace:
b = []byte{127}
case tea.KeyEscape:
b = []byte{27}
case tea.KeyUp:
b = []byte{27, '[', 'A'}
case tea.KeyDown:
b = []byte{27, '[', 'B'}
case tea.KeyRight:
b = []byte{27, '[', 'C'}
case tea.KeyLeft:
b = []byte{27, '[', 'D'}
case tea.KeySpace:
b = []byte{' '}
default:
// Regular character input
if msg.Text != "" {
b = []byte(msg.Text)
} else if msg.Code > 0 && msg.Code < 128 {
b = []byte{byte(msg.Code)}
}
}
}
if len(b) > 0 {
a.ExecPTY.Write(b)
}
}
// execPanelOrigin returns the top-left screen position of the content area
func (a *App) execPanelOrigin() (x, y int) {
w, h := a.execPanelSize()
panelW := w + 2
panelH := h + 2
x = (a.Width - panelW) / 2
y = (a.Height - panelH) / 2
x += 1
y += 1
return
}
// execMouseToContent maps screen mouse position to VTE row/col
func (a *App) execMouseToContent(mx, my int) (row, col int) {
ox, oy := a.execPanelOrigin()
w, h := a.execPanelSize()
col = mx - ox
row = my - oy
if col < 0 || col >= w || row < 0 || row >= h {
return -1, -1
}
return row, col
}
// execGetVisibleLines reads the VTE screen buffer into strings
func (a *App) execGetVisibleLines() []string {
if a.ExecVT == nil {
return nil
}
a.ExecVT.Lock()
defer a.ExecVT.Unlock()
cols, rows := a.ExecVT.Size()
lines := make([]string, rows)
for y := 0; y < rows; y++ {
var sb strings.Builder
for x := 0; x < cols; x++ {
g := a.ExecVT.Cell(x, y)
if g.Char == 0 {
sb.WriteRune(' ')
} else {
sb.WriteRune(g.Char)
}
}
lines[y] = strings.TrimRight(sb.String(), " ")
}
return lines
}
// execSelectedText extracts text from the selection range
func (a *App) execSelectedText(visible []string, w int) string {
if !a.ExecSelActive {
return ""
}
sr, sc, er, ec := a.ExecSelStartR, a.ExecSelStartC, a.ExecSelEndR, a.ExecSelEndC
if sr > er || (sr == er && sc > ec) {
sr, sc, er, ec = er, ec, sr, sc
}
var sb strings.Builder
for r := sr; r <= er && r < len(visible); r++ {
runes := []rune(visible[r])
if len(runes) > w {
runes = runes[:w]
}
cStart := 0
cEnd := len(runes)
if r == sr { cStart = sc }
if r == er { cEnd = ec + 1 }
if cStart > len(runes) { cStart = len(runes) }
if cEnd > len(runes) { cEnd = len(runes) }
if cStart < cEnd {
sb.WriteString(string(runes[cStart:cEnd]))
}
if r < er {
sb.WriteString("\n")
}
}
return sb.String()
}
// execCopySelection copies the mouse-selected text to clipboard
func (a *App) execCopySelection() tea.Cmd {
w, _ := a.execPanelSize()
visible := a.execGetVisibleLines()
text := a.execSelectedText(visible, w)
if text == "" {
return nil
}
return a.copyToClipboard(text)
}
// copyToClipboard sends text to system clipboard
func (a *App) copyToClipboard(text string) tea.Cmd {
return func() tea.Msg {
// Try native clipboard tools first
for _, cmd := range [][]string{
{"wl-copy"},
{"xclip", "-selection", "clipboard"},
{"xsel", "--clipboard", "--input"},
} {
c := exec.Command(cmd[0], cmd[1:]...)
c.Stdin = strings.NewReader(text)
if err := c.Run(); err == nil {
return ContainerActionMsg{Action: "copied to clipboard"}
}
}
// Fallback: OSC 52 escape sequence (works in most modern terminals)
encoded := base64.StdEncoding.EncodeToString([]byte(text))
fmt.Fprintf(os.Stderr, "\x1b]52;c;%s\x07", encoded)
return ContainerActionMsg{Action: "copied to clipboard (OSC 52)"}
}
}
func (a *App) updateExecMode(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case ContainerActionMsg:
return a, a.setInfo(msg.Action)
case InfoDismissMsg:
a.InfoText = ""
return a, nil
case ExecOutputMsg:
// VTE parses in background; just keep ticking to refresh the view
if a.ExecMode {
// Check if the exec process has exited
select {
case <-a.ExecDone:
a.execClose()
return a, nil
default:
}
return a, a.execTick()
}
return a, nil
case ExecExitMsg:
a.execClose()
return a, nil
case tea.WindowSizeMsg:
a.Width = msg.Width
a.Height = msg.Height
// Resize VTE + PTY to match new panel size
if a.ExecVT != nil && a.ExecPTY != nil {
w, h := a.execPanelSize()
a.ExecVT.Lock()
a.ExecVT.Resize(w, h)
a.ExecVT.Unlock()
_ = pty.Setsize(a.ExecPTY, &pty.Winsize{Rows: uint16(h), Cols: uint16(w)})
}
return a, nil
case tea.KeyPressMsg:
// Clear selection on any keypress
a.ExecSelActive = false
a.ExecSelecting = false
// Ctrl+Q exits exec mode
if msg.Code == 'q' && msg.Mod&tea.ModCtrl != 0 {
a.execClose()
return a, nil
}
// Forward all keys directly to PTY
a.execWriteKey(msg)
return a, nil
case tea.MouseClickMsg:
if msg.Button == tea.MouseLeft {
r, c := a.execMouseToContent(msg.X, msg.Y)
if r >= 0 {
a.ExecSelecting = true
a.ExecSelActive = true
a.ExecSelStartR = r
a.ExecSelStartC = c
a.ExecSelEndR = r
a.ExecSelEndC = c
}
}
return a, nil
case tea.MouseMotionMsg:
if a.ExecSelecting {
r, c := a.execMouseToContent(msg.X, msg.Y)
if r >= 0 {
a.ExecSelEndR = r
a.ExecSelEndC = c
}
}
return a, nil
case tea.MouseReleaseMsg:
if a.ExecSelecting {
a.ExecSelecting = false
r, c := a.execMouseToContent(msg.X, msg.Y)
if r >= 0 {
a.ExecSelEndR = r
a.ExecSelEndC = c
}
if a.ExecSelStartR == a.ExecSelEndR && a.ExecSelStartC == a.ExecSelEndC {
a.ExecSelActive = false
} else {
return a, a.execCopySelection()
}
}
return a, nil
}
return a, nil
}
func (a *App) viewExec() string {
w, h := a.execPanelSize()
// Title bar
title := fmt.Sprintf(" exec: %s (%s) [%s] ", a.ExecContainer, a.ExecImage, a.ExecID)
hint := " Ctrl+Q to exit "
// Styles
borderColor := lipgloss.NewStyle().Foreground(cGreen).Background(cMantle)
titleStyle := lipgloss.NewStyle().Foreground(cPeach).Background(cMantle).Bold(true)
hintStyle := lipgloss.NewStyle().Foreground(cOverlay0).Background(cMantle)
lineStyle := lipgloss.NewStyle().Foreground(cText).Background(cBase)
cursorStyle := lipgloss.NewStyle().Foreground(cBase).Background(cText)
selStyle := lipgloss.NewStyle().Foreground(cBase).Background(cBlue)
// Top border
titleLen := len([]rune(title))
hintLen := len([]rune(hint))
topW := w + 2
topAvail := topW - 2 - titleLen
if topAvail < 0 { topAvail = 0 }
topBorder := borderColor.Render("╭") +
borderColor.Render("─") +
titleStyle.Render(title) +
borderColor.Render(strings.Repeat("─", max(0, topAvail-1))) +
borderColor.Render("╮")
// Bottom border
botAvail := topW - 2 - hintLen
if botAvail < 0 { botAvail = 0 }
botBorder := borderColor.Render("╰") +
borderColor.Render("─") +
hintStyle.Render(hint) +
borderColor.Render(strings.Repeat("─", max(0, botAvail-1))) +
borderColor.Render("╯")
// Read VTE screen buffer
var curX, curY int
visible := make([]string, h)
if a.ExecVT != nil {
a.ExecVT.Lock()
cur := a.ExecVT.Cursor()
curX, curY = cur.X, cur.Y
for y := 0; y < h; y++ {
var sb strings.Builder
for x := 0; x < w; x++ {
g := a.ExecVT.Cell(x, y)
if g.Char == 0 {
sb.WriteRune(' ')
} else {
sb.WriteRune(g.Char)
}
}
visible[y] = sb.String()
}
a.ExecVT.Unlock()
}
// Normalize selection bounds
selSR, selSC, selER, selEC := a.ExecSelStartR, a.ExecSelStartC, a.ExecSelEndR, a.ExecSelEndC
if selSR > selER || (selSR == selER && selSC > selEC) {
selSR, selSC, selER, selEC = selER, selEC, selSR, selSC
}
// Render content lines from VTE buffer
var content strings.Builder
for y := 0; y < h; y++ {
var line string
if y < len(visible) {
line = visible[y]
}
runes := []rune(line)
// Pad to width
for len(runes) < w {
runes = append(runes, ' ')
}
if len(runes) > w {
runes = runes[:w]
}
// Check if this line has selection or cursor
hasSel := a.ExecSelActive && y >= selSR && y <= selER
hasCursor := y == curY
if !hasSel && !hasCursor {
// Fast path: render entire line at once
content.WriteString(borderColor.Render("│") + lineStyle.Render(string(runes)) + borderColor.Render("│") + "\n")
} else {
// Char-by-char for cursor and selection
var sb strings.Builder
for x := 0; x < w; x++ {
ch := string(runes[x])
isCursor := hasCursor && x == curX
inSel := hasSel
if inSel {
colStart := 0
colEnd := w
if y == selSR { colStart = selSC }
if y == selER { colEnd = selEC + 1 }
inSel = x >= colStart && x < colEnd
}
if inSel {
sb.WriteString(selStyle.Render(ch))
} else if isCursor {
sb.WriteString(cursorStyle.Render(ch))
} else {
sb.WriteString(lineStyle.Render(ch))
}
}
content.WriteString(borderColor.Render("│") + sb.String() + borderColor.Render("│") + "\n")
}
}
panel := topBorder + "\n" + content.String() + botBorder
bg := a.viewNormal()
return placeOverlay(bg, panel, lipgloss.Center, lipgloss.Center, a.Width, a.Height)
}
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`)
func stripAnsi(s string) string {
return ansiRegex.ReplaceAllString(s, "")
}
func (a *App) setInfo(text string) tea.Cmd {
a.InfoText = text
return tea.Tick(4*time.Second, func(t time.Time) tea.Msg {
return InfoDismissMsg{}
})
}
// ============================================================
// DATA LOADING
// ============================================================
func (a *App) loadContainers() tea.Cmd {
mgr := a.CmdMgr
showSelf := a.Config.ShowSelf
return func() tea.Msg {
if mgr == nil {
return ErrorMsg{Err: fmt.Errorf("docker not connected")}
}
containers, err := mgr.ListContainers(true)
if err != nil {
return ErrorMsg{Err: err}
}
result := make([]Container, 0, len(containers))
for _, c := range containers {
name := ""
if len(c.Names) > 0 {
name = strings.TrimPrefix(c.Names[0], "/")
}
// Skip self (oxker container) unless ShowSelf is set
if !showSelf && strings.Contains(c.Image, "oxker") {
continue
}
result = append(result, Container{
ID: c.ID,
Name: name,
Image: c.Image,
State: mapState(c.State, c.Status),
StateStr: c.State,
Status: c.Status,
Ports: mapPorts(c.Ports),
CreatedAt: c.Created,
})
}
return ContainersLoadedMsg{Containers: result}
}
}
func (a *App) updateAllStats() []tea.Cmd {
if a.CmdMgr == nil { return nil }
if a.PendingStats == nil { a.PendingStats = make(map[string]bool) }
var cmds []tea.Cmd
mgr := a.CmdMgr
for _, c := range a.Containers {
if c.State != RunningHealthy && c.State != RunningUnhealthy && c.State != Paused {
continue
}
id := c.ID
if a.PendingStats[id] {
continue // already in-flight
}
a.PendingStats[id] = true
cmds = append(cmds, func() tea.Msg {
stats, err := mgr.ContainerStats(id)
if err != nil {
return nil // silently ignore stats errors
}
cpu := calculateCPU(stats)
memUsage := stats.MemoryStats.Usage
// Subtract cache
if cache, ok := stats.MemoryStats.Stats["inactive_file"]; ok {
if memUsage > cache {
memUsage -= cache
}
}
memLimit := stats.MemoryStats.Limit
var rx, tx uint64
for _, net := range stats.Networks {
rx += net.RxBytes
tx += net.TxBytes
}
return StatsUpdatedMsg{
ID: id, CPU: cpu,
MemUsage: memUsage, MemLimit: memLimit,
Rx: rx, Tx: tx,
}
})
}
return cmds
}
func (a *App) loadLogsForSelected() tea.Cmd {
a.syncLogSelection()
c := a.selected()
if c == nil || a.CmdMgr == nil { return nil }
id := c.ID
since := c.LogsSince
mgr := a.CmdMgr
showStderr := a.Config.ShowStdErr
return func() tea.Msg {
opts := container.LogsOptions{
Follow: false, ShowStdout: true, ShowStderr: showStderr,
Timestamps: true,
}
if since != "" {
opts.Since = since
} else {
opts.Tail = "500"
}
rc, err := mgr.Logs(id, opts)
if err != nil {
// Container may have been removed between list and log fetch; silently return empty
return LogsLoadedMsg{ID: id, Lines: nil}
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
return LogsLoadedMsg{ID: id, Lines: nil}
}
raw := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
var cleaned []string
for _, line := range raw {
if len(line) > 8 && (line[0] == 0 || line[0] == 1 || line[0] == 2) {
line = line[8:]
}
if line != "" {
cleaned = append(cleaned, line)
}
}
return LogsLoadedMsg{ID: id, Lines: cleaned, Since: since}
}
}
// ============================================================
// CPU CALCULATION (matching original Rust formula)
// ============================================================
func calculateCPU(stats *container.StatsResponse) float64 {
// Guard against uint64 underflow (saturating_sub equivalent)
if stats.CPUStats.CPUUsage.TotalUsage < stats.PreCPUStats.CPUUsage.TotalUsage {
return 0
}
if stats.CPUStats.SystemUsage < stats.PreCPUStats.SystemUsage {
return 0
}
cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage - stats.PreCPUStats.CPUUsage.TotalUsage)
systemDelta := float64(stats.CPUStats.SystemUsage - stats.PreCPUStats.SystemUsage)
if cpuDelta <= 0 || systemDelta <= 0 {
return 0
}
onlineCPUs := float64(stats.CPUStats.OnlineCPUs)
if onlineCPUs == 0 {
onlineCPUs = float64(len(stats.CPUStats.CPUUsage.PercpuUsage))
}
if onlineCPUs == 0 {
onlineCPUs = 1
}
return (cpuDelta / systemDelta) * onlineCPUs * 100.0
}
// ============================================================
// VIEW
// ============================================================
// Charm.sh / Catppuccin Mocha inspired color palette
var (
cBase = lipgloss.Color("#1e1e2e")
cMantle = lipgloss.Color("#181825")
cSurface0 = lipgloss.Color("#313244")
cSurface1 = lipgloss.Color("#45475a")
cOverlay0 = lipgloss.Color("#6c7086")
cText = lipgloss.Color("#cdd6f4")
cSubtext0 = lipgloss.Color("#a6adc8")
cRed = lipgloss.Color("#f38ba8")
cGreen = lipgloss.Color("#a6e3a1")
cYellow = lipgloss.Color("#f9e2af")
cBlue = lipgloss.Color("#89b4fa")
cMauve = lipgloss.Color("#cba6f7")
cTeal = lipgloss.Color("#94e2d5")
cPeach = lipgloss.Color("#fab387")
cLavender = lipgloss.Color("#b4befe")
cFlamingo = lipgloss.Color("#f2cdcd")
)
// colorOr returns the config color if non-empty, otherwise the default
func colorOr(cfgColor string, def color.Color) color.Color {
if cfgColor != "" { return lipgloss.Color(cfgColor) }
return def
}
// Resolved colors from config with defaults
func (a *App) borderSelected() color.Color { return colorOr(a.Config.AppColors.Borders.Selected, cBlue) }
func (a *App) borderUnselected() color.Color { return colorOr(a.Config.AppColors.Borders.Unselected, cSurface0) }
func (a *App) cmdColorCfg(cmd string) color.Color {
cc := a.Config.AppColors.Commands
switch cmd {
case "start": return colorOr(cc.Start, cGreen)
case "resume": return colorOr(cc.Resume, cBlue)
case "restart": return colorOr(cc.Restart, cMauve)
case "pause": return colorOr(cc.Pause, cYellow)
case "stop": return colorOr(cc.Stop, cRed)
case "delete": return colorOr(cc.Delete, cOverlay0)
default: return cText
}
}
func (a *App) stateColorCfg(s ContainerState) color.Color {
cs := a.Config.AppColors.ContainerState
switch s {
case RunningHealthy: return colorOr(cs.RunningHealthy, cGreen)
case RunningUnhealthy: return colorOr(cs.RunningUnhealthy, cPeach)
case Paused: return colorOr(cs.Paused, cYellow)
case Exited: return colorOr(cs.Exited, cRed)
case Dead: return colorOr(cs.Dead, cRed)
case Restarting: return colorOr(cs.Restarting, cTeal)
case Removing: return colorOr(cs.Removing, cRed)
default: return colorOr(cs.Unknown, cOverlay0)
}
}
func (a *App) View() tea.View {
var content string
if a.Width == 0 || a.Height == 0 {
content = "Loading..."
} else if a.Width < 60 || a.Height < 10 {
content = a.centeredMsg(fmt.Sprintf("Terminal too small (%dx%d)\nMinimum: 60x10", a.Width, a.Height))
} else if a.State == StateLoading {
content = a.centeredMsg("Loading Docker containers...")
} else if a.ExecMode {
content = a.viewExec()
} else if a.InspectMode {
content = a.viewInspect()
} else {
content = a.viewNormal()
}
mouseMode := tea.MouseModeCellMotion
if !a.MouseEnabled {
mouseMode = tea.MouseModeNone
}
return tea.View{
Content: content,
AltScreen: true,
MouseMode: mouseMode,
}
}
func (a *App) viewNormal() string {
containerH := a.containerAreaHeight()
logsH := a.logsAreaHeight()
chartsH := a.chartsAreaHeight()
// Clamp
total := containerH + logsH + chartsH
if total > a.Height {
logsH -= total - a.Height
if logsH < 3 { logsH = 3 }
}
var sections []string
sections = append(sections, a.viewContainerSection(containerH))
if a.ShowLogs {
sections = append(sections, a.viewLogs(logsH))
}
sections = append(sections, a.viewBottom(chartsH))
result := lipgloss.JoinVertical(lipgloss.Left, sections...)
// Clamp to terminal height to prevent overflow causing layout jumping.
// Also pad every line to full width so the ANSI parser in the overlay
// always produces cells with an explicit BG, preventing the underlying
// pane from bleeding through on the right side.
bgPad := lipgloss.NewStyle().Background(cBase)
lines := strings.Split(result, "\n")
if len(lines) > a.Height {
lines = lines[:a.Height]
}
for i, line := range lines {
w := lipgloss.Width(line)
if w < a.Width {
lines[i] = line + bgPad.Render(strings.Repeat(" ", a.Width-w))
}
}
result = strings.Join(lines, "\n")
// Overlays
if a.ShowHelp {
result = a.overlayHelp(result)
}
if a.DeleteConfirm {
result = a.overlayDeleteConfirm(result)
}
if a.Error != nil {
result = a.overlayError(result)
}
if a.InfoText != "" {
result = a.overlayInfo(result)
}
return result
}
func (a *App) centeredMsg(msg string) string {
return lipgloss.Place(a.Width, a.Height, lipgloss.Center, lipgloss.Center,
lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(cBlue).Padding(1, 3).Render(msg))
}
// SpinnerChar returns the current braille spinner frame character.
func (a *App) SpinnerChar() string {
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
return frames[a.LoadingIdx%len(frames)]
}
func (a *App) sortIcon(col SortColumn) string {
if a.SortCol == col && a.SortOrd != SortNone {
if a.SortOrd == SortAsc {
return "▲"
}
return "▼"
}
return ""
}
type colW struct{ name, state, status, cpu, mem, id, image, rx, tx int }
func (a *App) colWidths() colW {
cmdW := a.Width * 10 / 100
if cmdW < 12 { cmdW = 12 }
inner := a.Width - cmdW - 2 - 1 - 3 // -2 for table borders, -1 for right padding, -3 for row selector prefix
if inner < 60 { inner = 60 }
// Fixed-width columns (content has predictable size)
const (
cpuW = 8 // "99.99%"
stateW = 3 // icon only (✓/✗/‖)
idW = 10 // 8 chars + pad
rxW = 9 // "999.9 MB"
txW = 9
)
// Compute max widths from data for variable columns (use rune count for unicode)
nameMax, statusMax, memMax, imageMax := 6, 8, 8, 7 // header minimums
for _, c := range a.filtered() {
if n := len([]rune(c.Name)); n > nameMax { nameMax = n }
if n := len([]rune(c.Status)); n > statusMax { statusMax = n }
memStr := fmtBytes(c.MemUsage) + " / " + fmtBytes(c.MemLimit)
if n := len([]rune(memStr)); n > memMax { memMax = n }
if n := len([]rune(c.Image)); n > imageMax { imageMax = n }
}
fixed := cpuW + stateW + idW + rxW + txW
avail := inner - fixed
if avail < 30 { avail = 30 }
// Proportional distribution of variable columns
total := nameMax + statusMax + memMax + imageMax
if total == 0 { total = 1 }
nameW := avail * nameMax / total
statusW := avail * statusMax / total
memW := avail * memMax / total
imageW := avail - nameW - statusW - memW // give remainder to image
// Apply minimums
if nameW < 6 { nameW = 6 }
if statusW < 8 { statusW = 8 }
if memW < 8 { memW = 8 }
if imageW < 7 { imageW = 7 }
return colW{
name: nameW, state: stateW, status: statusW,
cpu: cpuW, mem: memW, id: idW,
image: imageW, rx: rxW, tx: txW,
}
}
// --- Container Section ---
func (a *App) viewContainerSection(h int) string {
cmdW := a.Width * 10 / 100
if cmdW < 12 { cmdW = 12 }
tableW := a.Width - cmdW
table := a.viewContainerTable(tableW, h)
cmds := a.viewCommands(cmdW, h)
return lipgloss.JoinHorizontal(lipgloss.Top, table, cmds)
}
func (a *App) viewContainerTable(w, h int) string {
bc := a.borderUnselected()
if a.ActivePanel == PanelContainers { bc = a.borderSelected() }
bStyle := lipgloss.NewStyle().Foreground(bc)
hStyle := lipgloss.NewStyle().Foreground(cOverlay0)
cw := a.colWidths()
cs := a.filtered()
innerW := w - 2
contentW := innerW - 1 // 1 char right padding inside border
// Build top border with embedded column headers.
type hCol struct {
label string
width int
}
cols := []hCol{
{"", 3}, // selection prefix
{"name" + a.sortIcon(SortName), cw.name},
{"", cw.state}, // state: icon only, no header
{"status" + a.sortIcon(SortStatus), cw.status},
{"cpu" + a.sortIcon(SortCPU), cw.cpu},
{"mem" + a.sortIcon(SortMemory), cw.mem},
{"id" + a.sortIcon(SortID), cw.id},
{"image" + a.sortIcon(SortImage), cw.image},
{"rx" + a.sortIcon(SortRX), cw.rx},
{"tx" + a.sortIcon(SortTX), cw.tx},
}
var top strings.Builder
top.WriteString(bStyle.Render("╭"))
used := 0
for _, c := range cols {
if c.label == "" {
top.WriteString(bStyle.Render(strings.Repeat("─", c.width)))
} else {
text := " " + c.label + " "
textW := len([]rune(text))
if textW > c.width {
text = string([]rune(text)[:c.width])
textW = c.width
}
top.WriteString(hStyle.Render(text))
pad := c.width - textW
if pad > 0 {
top.WriteString(bStyle.Render(strings.Repeat("─", pad)))
}
}
used += c.width
}
if rem := innerW - used; rem > 0 {
top.WriteString(bStyle.Render(strings.Repeat("─", rem)))
}
top.WriteString(bStyle.Render("╮"))
var result strings.Builder
result.WriteString(top.String() + "\n")
contentH := h - 2 // minus top + bottom border
if a.FilterMode {
filterLabel := lipgloss.NewStyle().Foreground(cYellow).Bold(true).Render(fmt.Sprintf("Filter(%s): ", a.FilterBy))
filterInput := lipgloss.NewStyle().Foreground(cText).Render(a.FilterText + "_")
fl := filterLabel + filterInput
if flW := lipgloss.Width(fl); flW < contentW {
fl += strings.Repeat(" ", contentW-flW)
}
result.WriteString(bStyle.Render("│") + fl + " " + bStyle.Render("│") + "\n")
contentH--
}
vis := contentH
if vis < 1 { vis = 1 }
end := a.ScrollOffset + vis
if end > len(cs) { end = len(cs) }
rowsWritten := 0
if len(cs) == 0 {
msg := lipgloss.NewStyle().Foreground(cOverlay0).Render(" No containers found")
if msgW := lipgloss.Width(msg); msgW < contentW {
msg += strings.Repeat(" ", contentW-msgW)
}
result.WriteString(bStyle.Render("│") + msg + " " + bStyle.Render("│") + "\n")
rowsWritten++
} else {
for idx := a.ScrollOffset; idx < end; idx++ {
c := cs[idx]
selected := idx == a.SelectedIdx
var row strings.Builder
if selected {
row.WriteString(lipgloss.NewStyle().Foreground(cBlue).Render(" > "))
} else {
row.WriteString(" ")
}
textStyle := lipgloss.NewStyle().Foreground(cBlue)
rxStyle := lipgloss.NewStyle().Foreground(cTeal)
txStyle := lipgloss.NewStyle().Foreground(cPeach)
icon, _ := stateStyle(c.State)
sc := a.stateColorCfg(c.State)
row.WriteString(textStyle.Render(padR(c.Name, cw.name)))
row.WriteString(lipgloss.NewStyle().Foreground(sc).Render(padR(icon, cw.state)))
row.WriteString(lipgloss.NewStyle().Foreground(sc).Render(padR(trunc(c.Status, cw.status-1), cw.status)))
row.WriteString(padR(fmt.Sprintf("%05.2f%%", c.CPUPercent), cw.cpu))
row.WriteString(padR(fmtBytes(c.MemUsage)+" / "+fmtBytes(c.MemLimit), cw.mem))
idStr := c.ID
if len(idStr) > 8 { idStr = idStr[:8] }
row.WriteString(textStyle.Render(padR(idStr, cw.id)))
row.WriteString(textStyle.Render(padR(trunc(c.Image, cw.image-1), cw.image)))
row.WriteString(rxStyle.Render(padL(fmtBytes(c.RxBytes), cw.rx)))
row.WriteString(txStyle.Render(padL(fmtBytes(c.TxBytes), cw.tx)))
rowStr := row.String()
if selected {
rowStr = lipgloss.NewStyle().Bold(true).Render(rowStr)
}
if rowW := lipgloss.Width(rowStr); rowW < contentW {
rowStr += strings.Repeat(" ", contentW-rowW)
}
result.WriteString(bStyle.Render("│") + rowStr + " " + bStyle.Render("│") + "\n")
rowsWritten++
}
}
for rowsWritten < vis {
result.WriteString(bStyle.Render("│") + strings.Repeat(" ", contentW) + " " + bStyle.Render("│") + "\n")
rowsWritten++
}
result.WriteString(bStyle.Render("╰" + strings.Repeat("─", innerW) + "╯"))
return result.String()
}
func (a *App) viewCommands(w, h int) string {
bc := a.borderUnselected()
if a.ActivePanel == PanelCommands { bc = a.borderSelected() }
bStyle := lipgloss.NewStyle().Foreground(bc)
innerW := w - 2
innerH := h - 2
if innerW < 4 { innerW = 4 }
if innerH < 1 { innerH = 1 }
c := a.selected()
// Build command lines with spacing.
var cmdLines []string
if c != nil {
cmds := commandsForState(c.State)
if a.CmdSelectedIdx >= len(cmds) {
a.CmdSelectedIdx = 0
}
arrow := lipgloss.NewStyle().Foreground(cBlue).Bold(true).Render("▶ ")
for i, cmd := range cmds {
clr := a.cmdColorCfg(cmd)
if i == a.CmdSelectedIdx {
cmdLines = append(cmdLines, arrow+lipgloss.NewStyle().Foreground(clr).Bold(true).Render(cmd))
} else {
cmdLines = append(cmdLines, " "+lipgloss.NewStyle().Foreground(clr).Bold(true).Render(cmd))
}
}
}
// Build visual lines with blank lines between commands.
var visualLines []string
for i, line := range cmdLines {
visualLines = append(visualLines, line)
if i < len(cmdLines)-1 {
visualLines = append(visualLines, "") // blank line between
}
}
// Vertical centering: compute top padding.
topPad := (innerH - len(visualLines)) / 2
if topPad < 0 { topPad = 0 }
// Horizontal centering: find max visible width of command lines.
maxCmdW := 0
for _, line := range visualLines {
if lw := lipgloss.Width(line); lw > maxCmdW { maxCmdW = lw }
}
leftPad := (innerW - maxCmdW) / 2
if leftPad < 0 { leftPad = 0 }
var result strings.Builder
// Top border.
result.WriteString(bStyle.Render("╭"+strings.Repeat("─", innerW)+"╮") + "\n")
for i := 0; i < innerH; i++ {
lineIdx := i - topPad
line := ""
if lineIdx >= 0 && lineIdx < len(visualLines) {
line = strings.Repeat(" ", leftPad) + visualLines[lineIdx]
}
lineW := lipgloss.Width(line)
pad := innerW - lineW
if pad < 0 { pad = 0 }
result.WriteString(bStyle.Render("│") + line + strings.Repeat(" ", pad) + bStyle.Render("│"))
if i < innerH-1 {
result.WriteString("\n")
}
}
result.WriteString("\n" + bStyle.Render("╰"+strings.Repeat("─", innerW)+"╯"))
return result.String()
}
// --- Logs ---
func (a *App) viewLogs(h int) string {
bc := a.borderUnselected()
if a.ActivePanel == PanelLogs { bc = a.borderSelected() }
bStyle := lipgloss.NewStyle().Foreground(bc)
hStyle := lipgloss.NewStyle().Foreground(cOverlay0)
w := a.Width
innerW := w - 2
innerH := h - 2
if innerH < 1 { innerH = 1 }
c := a.selected()
name := ""
if c != nil { name = c.Name }
total := len(a.LogLines)
vis := a.logsVisibleRows()
end := a.LogScroll + vis
if end > total { end = total }
title := fmt.Sprintf("Logs %d/%d - %s", end, total, name)
// Top border with embedded title.
titleRendered := " " + hStyle.Render(title) + " "
titleW := lipgloss.Width(titleRendered)
leftDash := 1
rightDash := w - 2 - titleW - leftDash
if rightDash < 1 { rightDash = 1 }
var result strings.Builder
result.WriteString(bStyle.Render("╭"+strings.Repeat("─", leftDash)) + titleRendered + bStyle.Render(strings.Repeat("─", rightDash)+"╮") + "\n")
// Build content lines.
var contentLines []string
if a.SearchMode {
searchLabel := lipgloss.NewStyle().Foreground(cYellow).Render("Search: ")
searchInput := lipgloss.NewStyle().Foreground(cText).Render(a.SearchText + "_")
matchInfo := lipgloss.NewStyle().Foreground(cSubtext0).Render(fmt.Sprintf(" (%d matches)", len(a.SearchMatches)))
contentLines = append(contentLines, searchLabel+searchInput+matchInfo)
}
rows := innerH - len(contentLines)
if rows < 1 { rows = 1 }
if total == 0 {
if c != nil && c.LogsSince == "" {
contentLines = append(contentLines, lipgloss.NewStyle().Foreground(cOverlay0).Render("parsing logs..."))
} else {
contentLines = append(contentLines, lipgloss.NewStyle().Foreground(cOverlay0).Render("No logs available"))
}
} else {
start := a.LogScroll
end := start + rows
if end > total { end = total; start = end - rows; if start < 0 { start = 0 } }
maxW := innerW - 4
if maxW < 20 { maxW = 20 }
for i := start; i < end; i++ {
line := stripAnsi(a.LogLines[i])
runes := []rune(line)
if a.LogHScroll > 0 && len(runes) > a.LogHScroll {
runes = runes[a.LogHScroll:]
} else if a.LogHScroll > 0 {
runes = nil
}
if len(runes) > maxW { runes = runes[:maxW] }
line = string(runes)
prefix := " "
if i == end-1 {
prefix = lipgloss.NewStyle().Foreground(cBlue).Render("▶ ")
}
if a.SearchText != "" {
caseSensitive := a.Config != nil && a.Config.LogSearchCaseSensitive
line = highlightMatches(line, a.SearchText, caseSensitive)
}
contentLines = append(contentLines, prefix+line)
}
}
// Side borders around content.
for i := 0; i < innerH; i++ {
line := ""
if i < len(contentLines) { line = contentLines[i] }
lineW := lipgloss.Width(line)
pad := innerW - lineW
if pad < 0 { pad = 0 }
result.WriteString(bStyle.Render("│") + line + strings.Repeat(" ", pad) + bStyle.Render("│"))
if i < innerH-1 {
result.WriteString("\n")
}
}
result.WriteString("\n" + bStyle.Render("╰"+strings.Repeat("─", w-2)+"╯"))
return result.String()
}
// --- Bottom: Charts + Ports ---
func (a *App) viewBottom(h int) string {
c := a.selected()
portsW := a.Width * 22 / 100
if portsW < 28 { portsW = 28 }
remaining := a.Width - portsW
chartW := remaining / 3
bwW := remaining - 2*chartW // absorb integer-division remainder
cpu := a.viewSparkChart("cpu", chartW, h, c)
mem := a.viewSparkChart("memory", chartW, h, c)
bw := a.viewBWChart(bwW, h, c)
ports := a.viewPorts(portsW, h, c)
return lipgloss.JoinHorizontal(lipgloss.Top, cpu, mem, bw, ports)
}
func (a *App) viewSparkChart(typ string, w, h int, c *Container) string {
bc := a.borderUnselected()
var title string
var data []float64
var clr color.Color
var yLabel string
if c != nil {
isActive := c.State == RunningHealthy || c.State == RunningUnhealthy
switch typ {
case "cpu":
title = fmt.Sprintf("cpu %05.2f%%", c.CPUPercent)
data = c.CPUHist
if isActive { clr = cGreen } else { clr = cOverlay0 }
case "memory":
title = fmt.Sprintf("memory %s", fmtBytes(c.MemUsage))
for _, v := range c.MemHist { data = append(data, float64(v)) }
if isActive { clr = cBlue } else { clr = cOverlay0 }
}
} else {
switch typ {
case "cpu":
title = "cpu 00.00%"
clr = cGreen
case "memory":
title = "memory 0.00 kB"
clr = cBlue
}
}
// Y-axis label: max value
maxVal := 0.0
for _, v := range data {
if v > maxVal { maxVal = v }
}
if typ == "cpu" && maxVal > 0 {
yLabel = fmt.Sprintf("%05.2f%%", maxVal)
} else if typ == "memory" && maxVal > 0 {
yLabel = fmtBytes(uint64(maxVal))
}
return a.renderBrailleChart(title, yLabel, data, clr, bc, w, h)
}
func (a *App) viewBWChart(w, h int, c *Container) string {
bc := a.borderUnselected()
rxRate, txRate := "0.00 kB/s", "0.00 kB/s"
var rxData, txData []float64
if c != nil {
rxData = histDeltas(c.RxHist)
txData = histDeltas(c.TxHist)
if len(rxData) > 0 {
rxRate = fmtRate(uint64(rxData[len(rxData)-1]))
}
if len(txData) > 0 {
txRate = fmtRate(uint64(txData[len(txData)-1]))
}
}
// Build title to fit within chart border (w - 4 for corners and min dashes).
maxTitle := w - 4
rxLabel := "rx: " + rxRate
txLabel := "tx: " + txRate
fullLen := len([]rune(rxLabel)) + 1 + len([]rune(txLabel))
if fullLen > maxTitle {
rxRate = fmtRateCompact(rxData)
txRate = fmtRateCompact(txData)
}
valStyle := lipgloss.NewStyle().Foreground(cOverlay0)
title := lipgloss.NewStyle().Foreground(cTeal).Render("rx:") + " " + valStyle.Render(rxRate) +
" " + lipgloss.NewStyle().Foreground(cPeach).Render("tx:") + " " + valStyle.Render(txRate)
return a.renderBrailleBWChart(title, rxData, txData, rxRate, txRate, bc, w, h)
}
// renderBrailleChart renders a single-dataset braille chart with Y-axis label and title in border
func (a *App) renderBrailleChart(title, yLabel string, data []float64, clr, borderColor color.Color, w, h int) string {
innerH := h - 2 // border takes 2
innerW := w - 2 // border takes 2
if innerH < 1 { innerH = 1 }
if innerW < 4 { innerW = 4 }
yLabelW := 0
if yLabel != "" {
yLabelW = len([]rune(yLabel)) + 1 // +1 for separator
}
chartW := innerW - yLabelW
if chartW < 2 { chartW = 2 }
chart := brailleChart(data, chartW, innerH, clr)
chartLines := strings.Split(chart, "\n")
if len(chartLines) == 0 {
chartLines = []string{""}
}
// Build content with Y-axis label
var sb strings.Builder
yStyle := lipgloss.NewStyle().Foreground(clr)
for i, line := range chartLines {
if i == 0 && yLabel != "" {
sb.WriteString(yStyle.Render(padL(yLabel, yLabelW-1)))
sb.WriteString(lipgloss.NewStyle().Foreground(cSurface0).Render("│"))
} else if yLabelW > 0 {
sb.WriteString(strings.Repeat(" ", yLabelW-1))
sb.WriteString(lipgloss.NewStyle().Foreground(cSurface0).Render("│"))
}
sb.WriteString(line)
if i < len(chartLines)-1 {
sb.WriteString("\n")
}
}
// Title in top border (like original Rust)
titleRendered := " " + lipgloss.NewStyle().Foreground(cOverlay0).Render(title) + " "
titleW := lipgloss.Width(titleRendered)
// Truncate title if it won't fit between the corners + 2 dashes.
maxTitleW := w - 4
if titleW > maxTitleW && maxTitleW > 0 {
titleRendered = " " + lipgloss.NewStyle().Foreground(cOverlay0).Render(padR(title, maxTitleW-2)) + " "
titleW = lipgloss.Width(titleRendered)
}
leftDash := (w - 2 - titleW) / 2
if leftDash < 1 { leftDash = 1 }
rightDash := w - 2 - titleW - leftDash
if rightDash < 1 { rightDash = 1 }
// Bottom border
bottomBorder := "╰" + strings.Repeat("─", w-2) + "╯"
// Side borders around content
contentLines := strings.Split(sb.String(), "\n")
var result strings.Builder
bStyle := lipgloss.NewStyle().Foreground(borderColor)
// Render border segments separately so the title's ANSI reset
// doesn't kill the border color for trailing dashes.
result.WriteString(bStyle.Render("╭"+strings.Repeat("─", leftDash)) + titleRendered + bStyle.Render(strings.Repeat("─", rightDash)+"╮") + "\n")
for i := 0; i < innerH; i++ {
line := ""
if i < len(contentLines) { line = contentLines[i] }
lineW := lipgloss.Width(line)
pad := innerW - lineW
if pad < 0 { pad = 0 }
result.WriteString(bStyle.Render("│") + line + strings.Repeat(" ", pad) + bStyle.Render("│"))
if i < innerH-1 {
result.WriteString("\n")
}
}
result.WriteString("\n" + bStyle.Render(bottomBorder))
return result.String()
}
// renderBrailleBWChart renders RX/TX as two braille charts stacked, with title in border
func (a *App) renderBrailleBWChart(title string, rxData, txData []float64, rxRate, txRate string, borderColor color.Color, w, h int) string {
innerH := h - 2
innerW := w - 2
if innerH < 2 { innerH = 2 }
if innerW < 4 { innerW = 4 }
halfH := (innerH - 1) / 2 // -1 for separator between rx/tx
if halfH < 1 { halfH = 1 }
// Y-axis label width (use rx or tx rate)
yLabelW := 0
rxYLabel := fmtRateShort(rxData)
txYLabel := fmtRateShort(txData)
if len([]rune(rxYLabel)) > yLabelW { yLabelW = len([]rune(rxYLabel)) }
if len([]rune(txYLabel)) > yLabelW { yLabelW = len([]rune(txYLabel)) }
if yLabelW > 0 { yLabelW += 1 } // separator
chartW := innerW - yLabelW
if chartW < 2 { chartW = 2 }
rxChart := brailleChart(rxData, chartW, halfH, cTeal)
txChart := brailleChart(txData, chartW, halfH, cPeach)
rxLines := strings.Split(rxChart, "\n")
txLines := strings.Split(txChart, "\n")
// Build content
var sb strings.Builder
yStyle := lipgloss.NewStyle()
sepStyle := lipgloss.NewStyle().Foreground(cSurface0)
// RX section
for i, line := range rxLines {
if i == 0 && rxYLabel != "" {
sb.WriteString(yStyle.Foreground(cTeal).Render(padL(rxYLabel, yLabelW-1)))
sb.WriteString(sepStyle.Render("│"))
} else if yLabelW > 0 {
sb.WriteString(strings.Repeat(" ", yLabelW-1))
sb.WriteString(sepStyle.Render("│"))
}
sb.WriteString(line + "\n")
}
// TX section
for i, line := range txLines {
if i == 0 && txYLabel != "" {
sb.WriteString(yStyle.Foreground(cPeach).Render(padL(txYLabel, yLabelW-1)))
sb.WriteString(sepStyle.Render("│"))
} else if yLabelW > 0 {
sb.WriteString(strings.Repeat(" ", yLabelW-1))
sb.WriteString(sepStyle.Render("│"))
}
sb.WriteString(line)
if i < len(txLines)-1 {
sb.WriteString("\n")
}
}
// Title in top border
titleW := lipgloss.Width(title)
titleRendered := " " + title + " "
titleRW := titleW + 2
leftDash := (w - 2 - titleRW) / 2
if leftDash < 1 { leftDash = 1 }
rightDash := w - 2 - titleRW - leftDash
if rightDash < 1 { rightDash = 1 }
bottomBorder := "╰" + strings.Repeat("─", w-2) + "╯"
contentLines := strings.Split(sb.String(), "\n")
var result strings.Builder
bStyle := lipgloss.NewStyle().Foreground(borderColor)
// Render border segments separately so the title's ANSI reset
// doesn't kill the border color for trailing dashes.
result.WriteString(bStyle.Render("╭"+strings.Repeat("─", leftDash)) + titleRendered + bStyle.Render(strings.Repeat("─", rightDash)+"╮") + "\n")
for i := 0; i < innerH; i++ {
line := ""
if i < len(contentLines) { line = contentLines[i] }
lineW := lipgloss.Width(line)
pad := innerW - lineW
if pad < 0 { pad = 0 }
result.WriteString(bStyle.Render("│") + line + strings.Repeat(" ", pad) + bStyle.Render("│"))
if i < innerH-1 {
result.WriteString("\n")
}
}
result.WriteString("\n" + bStyle.Render(bottomBorder))
return result.String()
}
func fmtRateShort(data []float64) string {
if len(data) == 0 { return "" }
maxV := 0.0
for _, v := range data {
if v > maxV { maxV = v }
}
if maxV == 0 { return "" }
return fmtRate(uint64(maxV))
}
// histDeltas computes per-interval deltas from cumulative history
func histDeltas(hist []uint64) []float64 {
if len(hist) < 2 {
return nil
}
deltas := make([]float64, 0, len(hist)-1)
for i := 1; i < len(hist); i++ {
if hist[i] >= hist[i-1] {
deltas = append(deltas, float64(hist[i]-hist[i-1]))
} else {
deltas = append(deltas, 0)
}
}
return deltas
}
func (a *App) viewPorts(w, h int, c *Container) string {
bc := a.borderUnselected()
bStyle := lipgloss.NewStyle().Foreground(bc)
hStyle := lipgloss.NewStyle().Foreground(cOverlay0)
innerW := w - 2
innerH := h - 2
if innerW < 8 { innerW = 8 }
if innerH < 1 { innerH = 1 }
contentW := innerW - 2 // 1 char padding on each side
// Top border with centered title.
titleStr := "ports"
titleRendered := " " + hStyle.Render(titleStr) + " "
titleW := lipgloss.Width(titleRendered)
leftDash := (w - 2 - titleW) / 2
if leftDash < 1 { leftDash = 1 }
rightDash := w - 2 - titleW - leftDash
if rightDash < 1 { rightDash = 1 }
var result strings.Builder
result.WriteString(bStyle.Render("╭"+strings.Repeat("─", leftDash)) + titleRendered + bStyle.Render(strings.Repeat("─", rightDash)+"╮") + "\n")
var contentLines []string
if c == nil || len(c.Ports) == 0 {
contentLines = append(contentLines, lipgloss.NewStyle().Foreground(cOverlay0).Render("no ports"))
} else {
ipW := contentW * 40 / 100
if ipW < 4 { ipW = 4 }
pvtW := (contentW - ipW) / 2
pubW := contentW - ipW - pvtW
hdr := padR("ip", ipW) + padL("private", pvtW) + padL("public", pubW)
contentLines = append(contentLines, hStyle.Render(hdr))
for _, p := range c.Ports {
ip := p.IP
pubStr := ""
if p.Public > 0 {
pubStr = fmt.Sprintf("%d", p.Public)
}
row := padR(ip, ipW) + padL(fmt.Sprintf("%d", p.Private), pvtW) + padL(pubStr, pubW)
contentLines = append(contentLines, row)
}
}
for i := 0; i < innerH; i++ {
line := ""
if i < len(contentLines) { line = contentLines[i] }
lineW := lipgloss.Width(line)
pad := contentW - lineW
if pad < 0 { pad = 0 }
result.WriteString(bStyle.Render("│") + " " + line + strings.Repeat(" ", pad) + " " + bStyle.Render("│"))
if i < innerH-1 {
result.WriteString("\n")
}
}
result.WriteString("\n" + bStyle.Render("╰"+strings.Repeat("─", w-2)+"╯"))
return result.String()
}
// --- Overlays ---
func (a *App) overlayDeleteConfirm(base string) string {
name := "<unknown>"
for _, c := range a.Containers {
if c.ID == a.DeleteTarget { name = c.Name; break }
}
if a.DeleteTarget == "" {
a.DeleteConfirm = false
return base
}
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(cRed).
Background(cMantle).
Padding(1, 3).
Render(fmt.Sprintf("Delete container %s?\n\n (y) Confirm (n) Cancel",
lipgloss.NewStyle().Bold(true).Foreground(cPeach).Render(name)))
return placeOverlay(base, box, lipgloss.Center, lipgloss.Center, a.Width, a.Height)
}
func (a *App) overlayError(base string) string {
msg := fmt.Sprintf("Error: %v\n\nPress (c) to clear or (esc) to dismiss", a.Error)
if a.ConnectCountdown > 0 {
msg = fmt.Sprintf("Error: %v\n\nExiting in %d seconds... Press (q) to quit now", a.Error, a.ConnectCountdown)
}
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(cRed).
Foreground(cRed).
Padding(1, 3).
Render(msg)
return placeOverlay(base, box, lipgloss.Center, lipgloss.Center, a.Width, a.Height)
}
func (a *App) overlayInfo(base string) string {
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(cTeal).
Padding(0, 2).
Render(a.InfoText)
// Bottom right — overlay on base
return placeOverlay(base, box, lipgloss.Right, lipgloss.Bottom, a.Width, a.Height)
}
// --- Inspect View ---
func (a *App) viewInspect() string {
lines := strings.Split(a.InspectData, "\n")
total := len(lines)
maxLineW := 0
for _, l := range lines {
if len(l) > maxLineW { maxLineW = len(l) }
}
vis := a.Height - 4
if vis < 1 { vis = 1 }
start := a.InspectScrollY
if start >= total { start = total - 1 }
if start < 0 { start = 0 }
end := start + vis
if end > total { end = total }
c := a.selected()
name := ""
if c != nil { name = c.Name + " " + c.ID[:minI(8, len(c.ID))] }
titleTop := fmt.Sprintf("inspecting: %s (esc/i/q to exit)", name)
titleBot := fmt.Sprintf("↑ %d/%d ↓ ← %d/%d →", a.InspectScrollY, total, a.InspectScrollX, maxLineW)
var sb strings.Builder
for i := start; i < end; i++ {
line := lines[i]
if a.InspectScrollX > 0 && len(line) > a.InspectScrollX {
line = line[a.InspectScrollX:]
} else if a.InspectScrollX > 0 {
line = ""
}
// Wrap long lines instead of truncating
lineW := a.Width - 6
if lineW < 10 { lineW = 10 }
for len(line) > lineW {
sb.WriteString(line[:lineW] + "\n")
line = line[lineW:]
}
sb.WriteString(line + "\n")
}
style := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(cBlue).
Width(a.Width - 2).
Height(a.Height - 2)
header := lipgloss.NewStyle().Foreground(cPeach).Bold(true).Render(titleTop)
footer := lipgloss.NewStyle().Foreground(cSubtext0).Render(titleBot)
content := lipgloss.NewStyle().Foreground(cOverlay0).Render(sb.String())
return style.Render(header + "\n" + content + "\n" + footer)
}
// --- Help View ---
func (a *App) overlayHelp(base string) string {
configPath := ""
if a.Config != nil && a.Config.DirConfig != "" {
configPath = a.Config.DirConfig
}
var sb strings.Builder
sb.WriteString(" oxker — Docker TUI (Go)\n\n")
if configPath != "" {
sb.WriteString(fmt.Sprintf(" Config: %s\n\n", configPath))
}
sb.WriteString(` Navigation
j/k ↑/↓ Scroll containers/logs/commands
J/K Scroll 10x speed
PgUp/PgDn Page scroll
g/G Top / Bottom
Tab/Shift+Tab Switch panels (Containers → Commands → Logs)
←/→ Scroll logs horizontally
Sorting
1 Name 2 State 3 Status
4 CPU 5 Memory 6 ID
7 Image 8 RX 9 TX
0 Reset sort
Filtering & Search
/ Filter containers (←/→ change filter: Name/Image/Status/All)
# Search logs (↑/↓ or Ctrl+N/P next/prev match)
Container Actions
s Start x Stop r Restart
p Pause u Unpause d Delete
e Exec into container (sh)
Enter Execute selected command (in Commands panel)
Display
l Toggle logs panel
[ / ] Resize logs panel
i Inspect container (JSON, scroll with j/k/h/l)
S / Ctrl+S Save logs to file
c Clear errors
h Toggle help
Esc Dismiss overlays
Quit
q / Ctrl+C`)
help := sb.String()
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(cMauve).
Padding(1, 3).
Render(help)
return placeOverlay(base, box, lipgloss.Center, lipgloss.Center, a.Width, a.Height)
}
// ============================================================
// OVERLAY HELPER
// ============================================================
// placeOverlay renders fg on top of bg at the given position.
// hPos: lipgloss.Left, Center, Right. vPos: lipgloss.Top, Center, Bottom.
func placeOverlay(bg string, fg string, hPos, vPos lipgloss.Position, bgW, bgH int) string {
bgLines := strings.Split(bg, "\n")
fgLines := strings.Split(fg, "\n")
fgW := 0
for _, l := range fgLines {
if w := lipgloss.Width(l); w > fgW { fgW = w }
}
fgH := len(fgLines)
// Compute top-left corner of overlay
var x, y int
switch hPos {
case lipgloss.Left:
x = 0
case lipgloss.Right:
x = bgW - fgW
default: // Center
x = (bgW - fgW) / 2
}
switch vPos {
case lipgloss.Top:
y = 0
case lipgloss.Bottom:
y = bgH - fgH
default: // Center
y = (bgH - fgH) / 2
}
if x < 0 { x = 0 }
if y < 0 { y = 0 }
// Pad bg to ensure enough lines
for len(bgLines) < bgH {
bgLines = append(bgLines, strings.Repeat(" ", bgW))
}
// Overlay fg onto bg line by line
for i, fgLine := range fgLines {
bgIdx := y + i
if bgIdx >= len(bgLines) { break }
// Pad the fg line to fgW so overlay has consistent width
fgLineW := lipgloss.Width(fgLine)
if fgLineW < fgW {
fgLine += strings.Repeat(" ", fgW-fgLineW)
}
// Build: [left padding] + [fg line] + [right padding]
// We sacrifice the bg content under the overlay (acceptable for small overlays)
bgLine := bgLines[bgIdx]
bgLineW := lipgloss.Width(bgLine)
left := strings.Repeat(" ", x)
rightW := bgW - x - fgW
right := ""
if rightW > 0 {
right = strings.Repeat(" ", rightW)
}
// Try to preserve bg content outside the overlay area
// Left part: use bg line if it has content
if bgLineW > 0 && x > 0 {
left = lipgloss.NewStyle().Width(x).MaxWidth(x).Inline(true).Render(bgLine)
if lipgloss.Width(left) < x {
left += strings.Repeat(" ", x-lipgloss.Width(left))
}
}
bgLines[bgIdx] = left + fgLine + right
}
return strings.Join(bgLines[:bgH], "\n")
}
// ============================================================
// LAYOUT HELPERS
// ============================================================
func (a *App) containerAreaHeight() int {
h := a.Height * 30 / 100
if h < 6 { h = 6 }
return h
}
func (a *App) logsAreaHeight() int {
if !a.ShowLogs { return 0 }
ch := a.containerAreaHeight()
bh := a.chartsAreaHeight()
lh := a.Height - ch - bh
if lh < 5 { lh = 5 }
// Apply log height percentage
maxLogs := a.Height * a.LogHeight / 100
if lh > maxLogs { lh = maxLogs }
return lh
}
func (a *App) chartsAreaHeight() int {
h := a.Height * 22 / 100
if h < 7 { h = 7 }
return h
}
func (a *App) containerVisibleRows() int {
h := a.containerAreaHeight() - 2
if h < 1 { h = 1 }
return h
}
func (a *App) logsVisibleRows() int {
h := a.logsAreaHeight() - 2 // -2 for top/bottom border
if h < 1 { h = 1 }
return h
}
// ============================================================
// FILTERING
// ============================================================
func (a *App) filtered() []Container {
if a.FilterText == "" { return a.Containers }
term := strings.ToLower(a.FilterText)
var out []Container
for _, c := range a.Containers {
match := false
switch a.FilterBy {
case FilterByName:
match = strings.Contains(strings.ToLower(c.Name), term)
case FilterByImage:
match = strings.Contains(strings.ToLower(c.Image), term)
case FilterByStatus:
match = strings.Contains(strings.ToLower(c.Status), term)
case FilterByAll:
match = strings.Contains(strings.ToLower(c.Name), term) ||
strings.Contains(strings.ToLower(c.Image), term) ||
strings.Contains(strings.ToLower(c.Status), term)
}
if match { out = append(out, c) }
}
return out
}
// ============================================================
// HELPERS
// ============================================================
func stateStyle(s ContainerState) (string, color.Color) {
switch s {
case RunningHealthy: return "✓", cGreen
case RunningUnhealthy: return "!", cPeach
case Paused: return "‖", cYellow
case Exited: return "✖", cRed
case Dead: return "✖", cRed
case Restarting: return "↻", cTeal
case Removing: return "…", cRed
case Created: return "?", cOverlay0
default: return "?", cOverlay0
}
}
func commandsForState(s ContainerState) []string {
switch s {
case RunningHealthy:
return []string{"pause", "restart", "stop", "delete"}
case RunningUnhealthy:
return []string{"pause", "restart", "stop", "delete"}
case Paused:
return []string{"resume", "stop", "delete"}
case Exited, Dead, Created:
return []string{"start", "restart", "delete"}
case Unknown:
return []string{"delete"}
case Restarting:
return []string{"stop", "delete"}
case Removing:
return []string{"delete"}
default:
return []string{"delete"}
}
}
func cmdColor(cmd string) color.Color {
switch cmd {
case "start":
return cGreen
case "resume":
return cBlue
case "restart":
return cMauve
case "pause":
return cYellow
case "stop":
return cRed
case "delete":
return cOverlay0
default:
return cText
}
}
// brailleChart renders data as a braille dot chart (matching original Rust ratatui style).
// Each terminal cell is a 2×4 braille grid, giving (w*2) × (h*4) resolution.
func brailleChart(data []float64, w, h int, clr color.Color) string {
if w <= 0 || h <= 0 { return "" }
// Braille dot offsets: each char is 2 cols × 4 rows
// Bit positions: col0=[0,1,2,6] col1=[3,4,5,7] for rows 0,1,2,3
dotBits := [2][4]rune{
{0x01, 0x02, 0x04, 0x40}, // left column (col 0)
{0x08, 0x10, 0x20, 0x80}, // right column (col 1)
}
pixW := w * 2 // horizontal pixel resolution
pixH := h * 4 // vertical pixel resolution
// Take last pixW data points (each data point = 1 pixel column)
start := 0
if len(data) > pixW { start = len(data) - pixW }
visible := data[start:]
maxV := 0.0
for _, v := range visible {
if v > maxV { maxV = v }
}
if maxV == 0 { maxV = 1 }
// Create pixel grid (row 0 = bottom)
grid := make([][]bool, pixH)
for i := range grid {
grid[i] = make([]bool, pixW)
}
// Plot data points
for i, v := range visible {
py := int(v / maxV * float64(pixH-1))
if py >= pixH { py = pixH - 1 }
if py < 0 { py = 0 }
grid[py][i] = true
}
// Render braille characters
s := lipgloss.NewStyle().Foreground(clr)
dimS := lipgloss.NewStyle().Foreground(cSurface1)
var lines []string
for cellRow := h - 1; cellRow >= 0; cellRow-- {
var b strings.Builder
for cellCol := 0; cellCol < w; cellCol++ {
var ch rune = 0x2800 // braille base
hasDot := false
for dc := 0; dc < 2; dc++ {
for dr := 0; dr < 4; dr++ {
px := cellCol*2 + dc
// Map: cellRow*4 + dr, but row 0 of cell = top visually = highest pixel
py := cellRow*4 + (3 - dr)
if px < len(grid[0]) && py >= 0 && py < pixH && grid[py][px] {
ch |= dotBits[dc][dr]
hasDot = true
}
}
}
if hasDot {
b.WriteString(s.Render(string(ch)))
} else {
b.WriteString(dimS.Render(string(rune(0x2800)))) // empty braille
}
}
lines = append(lines, b.String())
}
return strings.Join(lines, "\n")
}
// SI units (1000-based, matching original)
func fmtBytes(b uint64) string {
if b == 0 { return "0.00 kB" }
kb := float64(b) / 1000
if kb < 1000 { return fmt.Sprintf("%.2f kB", kb) }
mb := kb / 1000
if mb < 1000 { return fmt.Sprintf("%.2f MB", mb) }
gb := mb / 1000
return fmt.Sprintf("%.2f GB", gb)
}
func fmtRate(b uint64) string {
if b == 0 { return "0.00 kb/s" }
kb := float64(b) / 1000
if kb < 1000 { return fmt.Sprintf("%.2f kb/s", kb) }
mb := kb / 1000
if mb < 1000 { return fmt.Sprintf("%.2f Mb/s", mb) }
gb := mb / 1000
return fmt.Sprintf("%.2f Gb/s", gb)
}
// fmtRateCompact returns a short rate string from the latest data point (e.g. "0.0k").
func fmtRateCompact(data []float64) string {
if len(data) == 0 { return "0k" }
v := data[len(data)-1]
if v < 1000 { return fmt.Sprintf("%.0fB", v) }
kb := v / 1000
if kb < 100 { return fmt.Sprintf("%.1fk", kb) }
if kb < 1000 { return fmt.Sprintf("%.0fk", kb) }
mb := kb / 1000
return fmt.Sprintf("%.1fM", mb)
}
func trunc(s string, n int) string {
if n <= 0 { return "" }
r := []rune(s)
if len(r) <= n { return s }
if n <= 3 { return string(r[:n]) }
return string(r[:n-3]) + "..."
}
func padR(s string, w int) string {
r := []rune(s)
if len(r) >= w { return string(r[:w]) }
return s + strings.Repeat(" ", w-len(r))
}
func padL(s string, w int) string {
r := []rune(s)
if len(r) >= w { return string(r[:w]) }
return strings.Repeat(" ", w-len(r)) + s
}
func highlightMatches(line, search string, caseSensitive bool) string {
haystack := line
term := search
if !caseSensitive {
haystack = strings.ToLower(line)
term = strings.ToLower(search)
}
idx := strings.Index(haystack, term)
if idx < 0 {
return line
}
hl := lipgloss.NewStyle().Foreground(cYellow).Bold(true)
var result strings.Builder
for idx >= 0 {
result.WriteString(line[:idx])
result.WriteString(hl.Render(line[idx : idx+len(search)]))
line = line[idx+len(search):]
if !caseSensitive {
haystack = strings.ToLower(line)
} else {
haystack = line
}
idx = strings.Index(haystack, term)
}
result.WriteString(line)
return result.String()
}
func minI(a, b int) int { if a < b { return a }; return b }
func maxI(a, b int) int { if a > b { return a }; return b }
// keyMatch checks if a key string matches any key in the slice (from config keymap)
func keyMatch(key string, bindings []string) bool {
for _, b := range bindings {
if key == b { return true }
}
return false
}
func compareFloat(a, b float64) int {
if math.Abs(a-b) < 0.001 { return 0 }
if a < b { return -1 }
return 1
}
func compareUint(a, b uint64) int {
if a == b { return 0 }
if a < b { return -1 }
return 1
}
func appendMax(s []float64, v float64, max int) []float64 {
if len(s) >= max {
copy(s, s[1:])
s[len(s)-1] = v
return s
}
return append(s, v)
}
func appendMaxU(s []uint64, v uint64, max int) []uint64 {
if len(s) >= max {
copy(s, s[1:])
s[len(s)-1] = v
return s
}
return append(s, v)
}
func mapState(state, status string) ContainerState {
switch strings.ToLower(state) {
case "running":
if strings.Contains(strings.ToLower(status), "unhealthy") {
return RunningUnhealthy
}
return RunningHealthy
case "exited":
return Exited
case "paused":
return Paused
case "restarting":
return Restarting
case "removing":
return Removing
case "dead":
return Dead
case "created":
return Created
default:
return Unknown
}
}
func mapPorts(ports []container.Port) []ContainerPorts {
result := make([]ContainerPorts, len(ports))
for i, p := range ports {
result[i] = ContainerPorts{IP: p.IP, Private: int(p.PrivatePort), Public: int(p.PublicPort)}
}
return result
}