aac139368f
Moves all packages from internal/ to pkg/ so they can be imported by external modules (needed for claude-pm integration). - internal/app/ → pkg/app/ - internal/docker/ → pkg/docker/ - internal/config/ → pkg/config/ - internal/utils/ → pkg/utils/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
3257 lines
82 KiB
Go
3257 lines
82 KiB
Go
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
|
||
|
||
// Header row (y == 0) → sort by column
|
||
if y == 0 {
|
||
col := a.headerColumnAt(x)
|
||
if col >= 0 {
|
||
a.toggleSort(SortColumn(col))
|
||
}
|
||
return a, nil
|
||
}
|
||
|
||
// Container area
|
||
containerH := a.containerAreaHeight()
|
||
cmdW := a.Width * 10 / 100
|
||
if cmdW < 12 { cmdW = 12 }
|
||
tableW := a.Width - cmdW - 4
|
||
|
||
if y >= 1 && y <= containerH {
|
||
if x < tableW+2 {
|
||
// Click in container table → select container
|
||
row := y - 3 + a.ScrollOffset // 3 = header + border + title
|
||
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
|
||
}
|
||
return a, nil
|
||
}
|
||
|
||
// Logs area
|
||
logsStart := 1 + 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
|
||
}
|
||
|
||
// ============================================================
|
||
// 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 := 1 + containerH + logsH + chartsH
|
||
if total > a.Height {
|
||
logsH -= total - a.Height
|
||
if logsH < 3 { logsH = 3 }
|
||
}
|
||
|
||
var sections []string
|
||
sections = append(sections, a.viewHeader())
|
||
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
|
||
lines := strings.Split(result, "\n")
|
||
if len(lines) > a.Height {
|
||
lines = lines[:a.Height]
|
||
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))
|
||
}
|
||
|
||
// --- Header ---
|
||
|
||
func (a *App) viewHeader() string {
|
||
bg := cMantle
|
||
fg := cText
|
||
hdr := lipgloss.NewStyle().Background(bg).Foreground(fg)
|
||
cw := a.colWidths()
|
||
|
||
spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||
spinner := lipgloss.NewStyle().Background(bg).Foreground(cMauve).Render(spinnerFrames[a.LoadingIdx])
|
||
|
||
sortIcon := func(col SortColumn) string {
|
||
if a.SortCol == col && a.SortOrd != SortNone {
|
||
if a.SortOrd == SortAsc { return " ▲" }
|
||
return " ▼"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
var b strings.Builder
|
||
b.WriteString(hdr.Width(1).Render(spinner))
|
||
b.WriteString(hdr.Width(2).Render(" "))
|
||
b.WriteString(hdr.Width(cw.name).Render("name" + sortIcon(SortName)))
|
||
b.WriteString(hdr.Width(cw.state).Render("state" + sortIcon(SortState)))
|
||
b.WriteString(hdr.Width(cw.status).Render("status" + sortIcon(SortStatus)))
|
||
b.WriteString(hdr.Width(cw.cpu).Render("cpu" + sortIcon(SortCPU)))
|
||
b.WriteString(hdr.Width(cw.mem).Render("memory/limit" + sortIcon(SortMemory)))
|
||
b.WriteString(hdr.Width(cw.id).Render("id" + sortIcon(SortID)))
|
||
b.WriteString(hdr.Width(cw.image).Render("image" + sortIcon(SortImage)))
|
||
b.WriteString(hdr.Width(cw.rx).Align(lipgloss.Right).Render("rx" + sortIcon(SortRX)))
|
||
b.WriteString(hdr.Width(cw.tx).Align(lipgloss.Right).Render("tx" + sortIcon(SortTX)))
|
||
|
||
content := b.String()
|
||
contentW := lipgloss.Width(content)
|
||
remaining := a.Width - contentW
|
||
if remaining > 0 {
|
||
helpText := "( h ) show help"
|
||
if a.ShowHelp { helpText = "( h ) exit help" }
|
||
hint := lipgloss.NewStyle().Background(bg).Foreground(cPeach).Width(remaining).Align(lipgloss.Right).Render(helpText)
|
||
content += hint
|
||
}
|
||
|
||
return content
|
||
}
|
||
|
||
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 - 6 - 3 // -6 for borders, -3 for row selector prefix
|
||
if inner < 60 { inner = 60 }
|
||
|
||
// Fixed-width columns (content has predictable size)
|
||
const (
|
||
cpuW = 8 // "99.99%"
|
||
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, stateMax, statusMax, memMax, imageMax := 6, 7, 8, 8, 7 // header minimums
|
||
for _, c := range a.filtered() {
|
||
if n := len([]rune(c.Name)); n > nameMax { nameMax = n }
|
||
stateLen := len([]rune(c.StateStr)) + 2 // icon + space
|
||
if stateLen > stateMax { stateMax = stateLen }
|
||
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 + idW + rxW + txW
|
||
avail := inner - fixed
|
||
if avail < 30 { avail = 30 }
|
||
|
||
// Proportional distribution of variable columns
|
||
total := nameMax + stateMax + statusMax + memMax + imageMax
|
||
if total == 0 { total = 1 }
|
||
|
||
nameW := avail * nameMax / total
|
||
stateW := avail * stateMax / total
|
||
statusW := avail * statusMax / total
|
||
memW := avail * memMax / total
|
||
imageW := avail - nameW - stateW - statusW - memW // give remainder to image
|
||
|
||
// Apply minimums
|
||
if nameW < 6 { nameW = 6 }
|
||
if stateW < 7 { stateW = 7 }
|
||
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 - 4
|
||
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() }
|
||
style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(bc).Width(w).Height(h - 2)
|
||
|
||
cs := a.filtered()
|
||
total := len(cs)
|
||
sel := a.SelectedIdx + 1
|
||
if total == 0 { sel = 0 }
|
||
|
||
title := fmt.Sprintf("Containers %d/%d", sel, total)
|
||
|
||
var sb strings.Builder
|
||
sb.WriteString(lipgloss.NewStyle().Foreground(cPeach).Bold(true).Render(title) + "\n")
|
||
|
||
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 + "_")
|
||
sb.WriteString(filterLabel + filterInput + "\n")
|
||
}
|
||
|
||
vis := h - 4
|
||
if vis < 1 { vis = 1 }
|
||
end := a.ScrollOffset + vis
|
||
if end > len(cs) { end = len(cs) }
|
||
cw := a.colWidths()
|
||
|
||
if total == 0 {
|
||
sb.WriteString(lipgloss.NewStyle().Foreground(cOverlay0).Render(" No containers found"))
|
||
return style.Render(sb.String())
|
||
}
|
||
|
||
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)))
|
||
stateText := icon + " " + c.StateStr
|
||
row.WriteString(lipgloss.NewStyle().Foreground(sc).Render(padR(stateText, 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)))
|
||
|
||
if selected {
|
||
sb.WriteString(lipgloss.NewStyle().Bold(true).Render(row.String()))
|
||
} else {
|
||
sb.WriteString(row.String())
|
||
}
|
||
sb.WriteString("\n")
|
||
}
|
||
|
||
return style.Render(sb.String())
|
||
}
|
||
|
||
func (a *App) viewCommands(w, h int) string {
|
||
bc := a.borderUnselected()
|
||
if a.ActivePanel == PanelCommands { bc = a.borderSelected() }
|
||
style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(bc).Width(w).Height(h - 2)
|
||
|
||
c := a.selected()
|
||
if c == nil { return style.Render("") }
|
||
|
||
cmds := commandsForState(c.State)
|
||
if a.CmdSelectedIdx >= len(cmds) {
|
||
a.CmdSelectedIdx = 0
|
||
}
|
||
arrow := lipgloss.NewStyle().Foreground(cBlue).Bold(true).Render("▶ ")
|
||
|
||
var sb strings.Builder
|
||
for i, cmd := range cmds {
|
||
clr := a.cmdColorCfg(cmd)
|
||
if i == a.CmdSelectedIdx {
|
||
sb.WriteString(arrow + lipgloss.NewStyle().Foreground(clr).Bold(true).Render(cmd) + "\n")
|
||
} else {
|
||
sb.WriteString(" " + lipgloss.NewStyle().Foreground(clr).Bold(true).Render(cmd) + "\n")
|
||
}
|
||
}
|
||
|
||
return style.Render(sb.String())
|
||
}
|
||
|
||
// --- Logs ---
|
||
|
||
func (a *App) viewLogs(h int) string {
|
||
bc := a.borderUnselected()
|
||
if a.ActivePanel == PanelLogs { bc = a.borderSelected() }
|
||
style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(bc).Width(a.Width - 2).Height(h - 2)
|
||
|
||
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)
|
||
|
||
var sb strings.Builder
|
||
sb.WriteString(lipgloss.NewStyle().Foreground(cPeach).Bold(true).Render(title) + "\n")
|
||
|
||
if a.SearchMode {
|
||
searchLabel := lipgloss.NewStyle().Foreground(cYellow).Bold(true).Render("Search: ")
|
||
searchInput := lipgloss.NewStyle().Foreground(cText).Render(a.SearchText + "_")
|
||
matchInfo := lipgloss.NewStyle().Foreground(cSubtext0).Render(fmt.Sprintf(" (%d matches)", len(a.SearchMatches)))
|
||
sb.WriteString(searchLabel + searchInput + matchInfo + "\n")
|
||
}
|
||
|
||
rows := h - 4
|
||
if rows < 1 { rows = 1 }
|
||
|
||
if total == 0 {
|
||
if c != nil && c.LogsSince == "" {
|
||
sb.WriteString(lipgloss.NewStyle().Foreground(cOverlay0).Render("parsing logs..."))
|
||
} else {
|
||
sb.WriteString(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 := a.Width - 8
|
||
if maxW < 20 { maxW = 20 }
|
||
|
||
for i := start; i < end; i++ {
|
||
line := stripAnsi(a.LogLines[i])
|
||
runes := []rune(line)
|
||
// Horizontal scroll (rune-based for UTF-8)
|
||
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("▶ ")
|
||
}
|
||
|
||
// Highlight search matches (substring-level)
|
||
if a.SearchText != "" {
|
||
caseSensitive := a.Config != nil && a.Config.LogSearchCaseSensitive
|
||
line = highlightMatches(line, a.SearchText, caseSensitive)
|
||
}
|
||
|
||
sb.WriteString(prefix + line + "\n")
|
||
}
|
||
}
|
||
|
||
return style.Render(sb.String())
|
||
}
|
||
|
||
// --- Bottom: Charts + Ports ---
|
||
|
||
func (a *App) viewBottom(h int) string {
|
||
c := a.selected()
|
||
portsW := a.Width * 18 / 100
|
||
if portsW < 22 { portsW = 22 }
|
||
remaining := a.Width - portsW - 8
|
||
chartW := remaining / 3
|
||
|
||
cpu := a.viewSparkChart("cpu", chartW, h, c)
|
||
mem := a.viewSparkChart("memory", chartW, h, c)
|
||
bw := a.viewBWChart(chartW, 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]))
|
||
}
|
||
}
|
||
|
||
title := lipgloss.NewStyle().Foreground(cTeal).Render("rx: "+rxRate) +
|
||
" " + lipgloss.NewStyle().Foreground(cPeach).Render("tx: "+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(cText).Bold(true).Render(title) + " "
|
||
titleW := lipgloss.Width(titleRendered)
|
||
topBorder := "╭"
|
||
leftDash := (w - 2 - titleW) / 2
|
||
if leftDash < 1 { leftDash = 1 }
|
||
rightDash := w - 2 - titleW - leftDash
|
||
if rightDash < 1 { rightDash = 1 }
|
||
topBorder += strings.Repeat("─", leftDash) + titleRendered + strings.Repeat("─", rightDash) + "╮"
|
||
|
||
// 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)
|
||
result.WriteString(bStyle.Render(topBorder) + "\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
|
||
topBorder := "╭"
|
||
leftDash := (w - 2 - titleRW) / 2
|
||
if leftDash < 1 { leftDash = 1 }
|
||
rightDash := w - 2 - titleRW - leftDash
|
||
if rightDash < 1 { rightDash = 1 }
|
||
topBorder += strings.Repeat("─", leftDash) + titleRendered + strings.Repeat("─", rightDash) + "╮"
|
||
|
||
bottomBorder := "╰" + strings.Repeat("─", w-2) + "╯"
|
||
|
||
contentLines := strings.Split(sb.String(), "\n")
|
||
var result strings.Builder
|
||
bStyle := lipgloss.NewStyle().Foreground(borderColor)
|
||
result.WriteString(bStyle.Render(topBorder) + "\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 {
|
||
style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(cSurface0).Width(w).Height(h - 2)
|
||
|
||
var sb strings.Builder
|
||
titleStr := "ports"
|
||
pad := (w - len(titleStr)) / 2
|
||
if pad < 0 { pad = 0 }
|
||
sb.WriteString(strings.Repeat(" ", pad) + lipgloss.NewStyle().Foreground(cText).Bold(true).Render(titleStr) + "\n")
|
||
|
||
if c == nil || len(c.Ports) == 0 {
|
||
sb.WriteString(lipgloss.NewStyle().Foreground(cOverlay0).Render("no ports"))
|
||
} else {
|
||
sb.WriteString(lipgloss.NewStyle().Foreground(cOverlay0).Render(fmt.Sprintf("%-10s %8s %8s", "ip", "private", "public")) + "\n")
|
||
for _, p := range c.Ports {
|
||
ip := p.IP
|
||
pubStr := ""
|
||
if p.Public > 0 {
|
||
pubStr = fmt.Sprintf("%d", p.Public)
|
||
}
|
||
sb.WriteString(fmt.Sprintf("%-10s %8d %8s\n", ip, p.Private, pubStr))
|
||
}
|
||
}
|
||
|
||
return style.Render(sb.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 - 1
|
||
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() - 4
|
||
if h < 1 { h = 1 }
|
||
return h
|
||
}
|
||
|
||
func (a *App) logsVisibleRows() int {
|
||
h := a.logsAreaHeight() - 4
|
||
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)
|
||
}
|
||
|
||
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
|
||
}
|