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