Files
tia-love/internal/app/app.go
T
oxpoe 5450a89a80
CI / test (push) Failing after 38s
layout: ikuti mock SVG — kolom tengah dashboard, toolbar workspace, kolom Aksi Tab
- Beranda: kolom tengah CTA 'masuk workspace' (klikable), strip Disclaimer
  dan Info aplikasi (versi/repo/config); geometri satu sumber (homeGeom).
- Workspace: baris toolbar — pill Cari (klik atau '/' → cari-lompat daftar)
  + tombol [+ stack]/[konversi] klikable (sementara keduanya jalur konverter).
- Detail kontainer: kolom kanan 'Aksi Tab' (mock TAB ACTION) berisi aksi tab
  aktif yang bisa diklik; disembunyikan di layar sempit agar tab bar utuh.
- Footer: hint aksi diprioritaskan; status diagnostik dibuang bila tak muat.
- internal/version: versi build dipakai CLI & strip Info aplikasi.
- audio: Play/Status aman untuk Engine nil (Model zero-value di tes).
- Tes: 3 skenario E2E baru (CTA, cari toolbar, kolom aksi) + tes layout.
2026-10-16 03:16:32 +07:00

1898 lines
49 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"bytes"
"context"
"fmt"
"os"
"regexp"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/config"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/stack"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/sysinfo"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/animation"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/theme"
)
type viewKind int
const (
viewHome viewKind = iota
viewList
)
type focusKind int
const (
focusSidebar focusKind = iota
focusContent
)
// Tab detail di panel konten (PRD §5.2, versi awal: Log berfungsi).
const (
tabLogs = iota
tabStats
tabConfig
tabEnv
tabTop
tabCount
)
// section — bagian resource di workspace (urutan PRD F-001:
// Stacks · Containers · Images · Volumes · Networks).
type section int
const (
sectionStacks section = iota
sectionContainers
sectionImages
sectionVolumes
sectionNetworks
sectionCount = 5
)
// statsHistory — jumlah sampel riwayat (60 ≈ 1 menit @1 dtk, PRD F-005).
const statsHistory = 60
// --- messages (async via tea.Cmd) ---
type containersMsg struct {
items []docker.Container
err error
}
type imagesMsg struct {
items []docker.Image
err error
}
type volumesMsg struct {
items []docker.Volume
err error
}
type networksMsg struct {
items []docker.Network
err error
}
type infoMsg struct {
info docker.Info
err error
}
type Model struct {
width, height int
sound *audio.Engine
toast components.Toast
tickID int64
animating bool
phase float64
frames int
fps int
lastFPSAt time.Time
view viewKind
section section
focus focusKind
tab int
searching bool // mode cari daftar (pill toolbar): ketik → lompat ke item
query string // isi pencarian; kosong = tidak menyaring
tailLines int
showTimestamps bool
logs []string
logCh chan string
logCancel context.CancelFunc
logID int64
logOffset int
logEOF bool
stats []docker.Stats
statsID int64
inspectID int64
inspectJSON []byte
inspectErr error
envID int64
env []string
envErr error
procsID int64
procsTitle []string
procsRows [][]string
procsErr error
detailScroll int
eventsCancel context.CancelFunc
eventsCh <-chan docker.Event
eventsID int64
activity []docker.Event
refreshGen int64
selID string
initCmds []tea.Cmd
hostname string
sys sysinfo.Stats
cl *docker.Client
compose *docker.ComposeClient
engine docker.Info
engineName string
stacksDir string
stacks []stack.Stack
stacksID int64
stacksErr error
stackBusy string // nama stack yang sedang menjalankan aksi
stackBusyAct string // kode aksi berjalan (u/s/R/p/D)
stackConfirm string // nama stack menunggu konfirmasi turunkan (D)
stackActID int64
modal *converterModal
containers []docker.Container
images []docker.Image
volumes []docker.Volume
networks []docker.Network
cursors [sectionCount]int
engineErr error
loadErr error
loading bool
}
func New() Model {
cfg, cfgErr := config.Load()
// shortcut: TIA_LANG = override khusus dev/tes; sumber resmi bahasa
// tetap config (ui.language) — dihapus saat layar Pengaturan jadi.
if l := os.Getenv("TIA_LANG"); l != "" {
cfg.UI.Language = l
}
i18n.SetLang(cfg.UI.Language)
theme.Init(theme.Mode(cfg.UI.ThemeMode), cfg.UI.ThemeAccent)
cl, engineInfo, err := docker.Detect(cfg.Docker.Host)
host, _ := os.Hostname()
m := Model{
sound: audio.New(audio.Options{
Enabled: cfg.UI.SoundEnabled,
Volume: cfg.UI.SoundVolume,
MuteOnSSH: cfg.UI.SoundMuteOnSSH,
}),
tickID: animation.NewID(),
animating: cfg.UI.AnimationEnabled,
toast: components.NewToast(),
view: viewHome,
section: sectionStacks,
focus: focusSidebar,
tab: tabLogs,
hostname: host,
sys: sysinfo.Read(),
cl: cl,
engineName: engineInfo.Name,
compose: docker.NewCompose(cfg.Docker.ComposeCommand, engineInfo.Name),
stacksDir: config.ExpandPath(cfg.StacksDir),
loading: true,
tailLines: cfg.UI.LogTailLines,
showTimestamps: cfg.UI.ShowTimestamps,
}
if cfgErr != nil {
m.toast.Show(components.LevelWarning, i18n.T("toast.configError"), cfgErr.Error())
}
if err != nil {
m.loadErr = err
m.engineErr = err
m.loading = false
}
// Perintah awal dikumpulkan di sini: Init() adalah value receiver sehingga
// tidak bisa memutasi model yang dipegang program (id stream, ctx cancel).
m.initCmds = []tea.Cmd{tea.RequestBackgroundColor} // untuk mode tema "auto"
if m.animating {
m.initCmds = append(m.initCmds, animation.Next(m.tickID))
}
if m.cl != nil {
m.initCmds = append(m.initCmds, loadAll(m.cl)...)
if cmd := m.startEvents(); cmd != nil {
m.initCmds = append(m.initCmds, cmd)
}
}
if m.compose != nil {
m.stacksID++
m.initCmds = append(m.initCmds, fetchStacks(m.stacksID, m.compose, m.stacksDir))
}
return m
}
func (m Model) Init() tea.Cmd { return tea.Batch(m.initCmds...) }
func loadAll(cl *docker.Client) []tea.Cmd {
return []tea.Cmd{
loadContainers(cl),
loadImages(cl),
loadVolumes(cl),
loadNetworks(cl),
loadInfo(cl),
}
}
func loadContainers(cl *docker.Client) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
items, err := cl.ListContainers(ctx)
return containersMsg{items: items, err: err}
}
}
func loadImages(cl *docker.Client) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
items, err := cl.ListImages(ctx)
return imagesMsg{items: items, err: err}
}
}
func loadVolumes(cl *docker.Client) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
items, err := cl.ListVolumes(ctx)
return volumesMsg{items: items, err: err}
}
}
func loadNetworks(cl *docker.Client) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
items, err := cl.ListNetworks(ctx)
return networksMsg{items: items, err: err}
}
}
func loadInfo(cl *docker.Client) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
info, err := cl.Info(ctx)
return infoMsg{info: info, err: err}
}
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case animation.TickMsg:
if msg.ID != m.tickID || !m.animating {
return m, nil // tick basi / reduced-motion
}
m.phase += 0.9
m.toast.Update(animation.FrameInterval)
m.frames++
if m.lastFPSAt.IsZero() {
m.lastFPSAt = msg.Time
}
if d := msg.Time.Sub(m.lastFPSAt); d >= time.Second {
m.fps = int(float64(m.frames) / d.Seconds())
m.frames = 0
m.lastFPSAt = msg.Time
m.sys = sysinfo.Read() // metrik host: refresh 1×/detik
}
return m, animation.Next(m.tickID)
case containersMsg:
mm, cmd := m.applyList(msg.err, func(mm *Model) { mm.containers = msg.items })
mm2, ok := mm.(Model)
if !ok {
return mm, cmd
}
// Auto-refresh dari events: restart stream detail HANYA bila kontainer
// terpilih berubah — kalau tidak, log yang mengalir jangan diputus.
newSel := ""
if mm2.section == sectionContainers && mm2.sectionLen() > 0 {
newSel = mm2.containers[min(mm2.cur(), mm2.sectionLen()-1)].ID
}
if newSel != m.selID {
return mm2, tea.Batch(cmd, mm2.restartData())
}
return mm2, cmd
case imagesMsg:
return m.applyList(msg.err, func(mm *Model) { mm.images = msg.items })
case volumesMsg:
return m.applyList(msg.err, func(mm *Model) { mm.volumes = msg.items })
case networksMsg:
return m.applyList(msg.err, func(mm *Model) { mm.networks = msg.items })
case infoMsg:
if msg.err != nil {
m.engineErr = msg.err
} else {
m.engineErr = nil
m.engine = msg.info
}
return m, nil
case logBatchMsg:
if msg.id != m.logID {
return m, nil // stream basi (seleksi sudah pindah)
}
if len(msg.lines) > 0 {
m.logs = append(m.logs, msg.lines...)
if over := len(m.logs) - m.tailLines; over > 0 {
m.logs = m.logs[over:]
}
}
if msg.eof {
m.logEOF = true
return m, nil
}
if m.logCh == nil {
return m, nil
}
return m, waitLogBatch(m.logID, m.logCh)
case logTickMsg:
if msg.id != m.logID || m.logCh == nil {
return m, nil
}
return m, waitLogBatch(m.logID, m.logCh)
case statsMsg:
if msg.id != m.statsID {
return m, nil // stream basi
}
if msg.err == nil {
m.stats = append(m.stats, msg.st)
if over := len(m.stats) - statsHistory; over > 0 {
m.stats = m.stats[over:]
}
}
return m, statsNext(m.statsID)
case statsTickMsg:
if msg.id != m.statsID || m.cl == nil || m.sectionLen() == 0 {
return m, nil
}
c := m.containers[min(m.cur(), m.sectionLen()-1)]
return m, fetchStats(m.statsID, m.cl, c.ID)
case inspectMsg:
if msg.id != m.inspectID {
return m, nil
}
m.inspectErr = msg.err
m.inspectJSON = msg.data
m.detailScroll = 0
return m, nil
case envMsg:
if msg.id != m.envID {
return m, nil
}
m.envErr = msg.err
m.env = msg.env
m.detailScroll = 0
return m, nil
case procsMsg:
if msg.id != m.procsID {
return m, nil
}
m.procsErr = msg.err
m.procsTitle, m.procsRows = msg.titles, msg.rows
return m, nil
case stacksMsg:
if msg.id != m.stacksID {
return m, nil
}
m.stacksErr = msg.err
if msg.err == nil {
m.stacks = msg.items
}
if n := len(m.stacks); m.cursors[sectionStacks] >= n {
m.cursors[sectionStacks] = max(0, n-1)
}
return m, nil
case stackActionMsg:
if msg.id != m.stackActID {
return m, nil
}
m.stackBusy = ""
// Aksi stack memengaruhi kontainer & status → segarkan keduanya.
cmds := []tea.Cmd{}
if m.compose != nil {
m.stacksID++
cmds = append(cmds, fetchStacks(m.stacksID, m.compose, m.stacksDir))
}
if m.cl != nil {
cmds = append(cmds, loadAll(m.cl)...)
}
if msg.err != nil {
m.toast.Show(components.LevelError, i18n.Tf("toast.stackFail", msg.name), lastLine(msg.out+" "+msg.err.Error()))
m.sound.Play(audio.NotifyError)
} else {
m.toast.Show(components.LevelSuccess, i18n.Tf("toast.stackOk", msg.name, msg.label), lastLine(msg.out))
m.sound.Play(audio.NotifySuccess)
}
return m, tea.Batch(cmds...)
case saveStackMsg:
if msg.err != nil {
if m.modal != nil {
m.modal.notice = msg.err.Error()
}
m.sound.Play(audio.NotifyError)
return m, nil
}
m.modal = nil
m.sound.Play(audio.NotifySuccess)
m.toast.Show(components.LevelSuccess, i18n.Tf("toast.stackSaved", msg.name), msg.dir)
m.stacksID++
cmds := []tea.Cmd{fetchStacks(m.stacksID, m.compose, m.stacksDir)}
if msg.run {
m.stackActID++
m.stackBusy = msg.name
m.stackBusyAct = "u"
cmds = append(cmds, runStackAction(m.stackActID, m.compose,
stack.Stack{Name: msg.name, Dir: msg.dir}, "u"))
}
return m, tea.Batch(cmds...)
case copyYAMLMsg:
if msg.err != nil {
m.toast.Show(components.LevelError, i18n.T("toast.copyFail"), msg.err.Error())
m.sound.Play(audio.NotifyError)
return m, nil
}
m.toast.Show(components.LevelInfo, i18n.T("toast.yamlCopied"), "")
m.sound.Play(audio.NotifySuccess)
return m, nil
case eventMsg:
if msg.id != m.eventsID {
return m, nil
}
m.activity = append(m.activity, msg.ev)
if over := len(m.activity) - activityMax; over > 0 {
m.activity = m.activity[over:]
}
m.refreshGen++ // debounce: segarkan daftar setelah burst event reda
return m, tea.Batch(waitEvent(msg.id, m.eventsCh), refreshAfter(m.refreshGen))
case eventsClosedMsg:
if msg.id != m.eventsID {
return m, nil
}
m.eventsCh = nil
return m, eventsRetry(m.eventsID) // daemon putus → sambung lagi nanti
case eventsRetryMsg:
if msg.id != m.eventsID {
return m, nil
}
return m, m.startEvents()
case refreshTickMsg:
if msg.gen != m.refreshGen || m.cl == nil {
return m, nil // event lebih baru menunggu tick berikutnya
}
cmds := loadAll(m.cl)
if m.compose != nil {
m.stacksID++
cmds = append(cmds, fetchStacks(m.stacksID, m.compose, m.stacksDir))
}
return m, tea.Batch(cmds...)
case tea.BackgroundColorMsg:
// Mode tema "auto": Mocha vs Latte sesuai background terminal.
theme.SetDetectedDark(msg.IsDark())
return m, nil
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case tea.KeyPressMsg:
if m.modal != nil {
return m, m.modalKeyMsg(msg) // modal menangkap semua tombol
}
if m.searching {
return m, m.searchKeyMsg(msg) // mode cari menangkap semua teks
}
return m, m.handleKey(msg.String())
case tea.PasteMsg:
if m.modal != nil {
m.modalPaste(msg.String())
}
case tea.MouseClickMsg:
if msg.Button == tea.MouseLeft {
if m.modal != nil {
return m, m.modalClick(msg.X, msg.Y) // klik masuk ke modal saja
}
if m.toast.Active() {
m.toast = components.NewToast() // klik = tutup toast
}
// Elemen UI eksplisit dulu (F-022): hint footer, toolbar, tab bar,
// kolom aksi tab, panel beranda.
if cmd, handled := m.mouseUI(msg.X, msg.Y); handled {
return m, cmd
}
m.sound.Play(audio.Click)
if m.view == viewList {
if msg.X >= m.leftWidth() {
m.focus = focusContent
} else {
m.focus = focusSidebar
if m.sectionLen() > 0 {
// baris item pertama = headerRows + 1 (border atas panel)
idx := msg.Y - (m.headerRows() + 1) + m.scrollStart()
if idx >= 0 && idx < m.sectionLen() {
m.setCur(idx)
return m, m.restartData()
}
}
}
}
}
case tea.MouseWheelMsg:
if m.modal != nil { // gulir pratinjau di modal
if msg.Button == tea.MouseWheelUp && m.modal.prevScroll > 0 {
m.modal.prevScroll--
} else if msg.Button == tea.MouseWheelDown && m.modal.prevScroll < max(0, len(m.modal.preview)-1) {
m.modal.prevScroll++
}
return m, nil
}
if m.view != viewList || m.focus != focusContent {
return m, nil
}
up := msg.Button == tea.MouseWheelUp
switch m.tab {
case tabConfig, tabEnv:
if up && m.detailScroll < m.detailScrollMax() {
m.detailScroll++
} else if !up && m.detailScroll > 0 {
m.detailScroll--
}
default: // tab Log
if up && m.logOffset < len(m.logs)-1 {
m.logOffset++
} else if !up && m.logOffset > 0 {
m.logOffset--
}
}
}
return m, nil
}
// applyList — jalur bersama semua hasil muat daftar.
// Dipanggil dengan pointer supaya mutasi set() mengenai salinan receiver
// yang memang dikembalikan (bug closure-by-value pernah terjadi di sini).
func (m Model) applyList(err error, set func(*Model)) (tea.Model, tea.Cmd) {
m.loading = false
if err != nil {
m.loadErr = err
m.toast.Show(components.LevelError, i18n.T("toast.dockerError"), err.Error())
m.sound.Play(audio.NotifyError)
return m, nil
}
m.loadErr = nil
set(&m)
for i := range m.cursors {
if n := m.lenOf(section(i)); m.cursors[i] >= n {
m.cursors[i] = max(0, n-1)
}
}
return m, nil
}
func (m *Model) jumpSection(s section) {
m.section = s
m.view = viewList
m.focus = focusSidebar
}
// stopLogs — matikan stream aktif tanpa memulai yang baru.
func (m *Model) stopLogs() {
if m.logCancel != nil {
m.logCancel()
m.logCancel = nil
}
m.logCh = nil
m.logs = nil
m.logOffset = 0
m.logEOF = false
}
// restartLogs — batalkan stream lama lalu buka stream baru bila konteksnya
// cocok (view list · section Kontainer · tab Log). Pola lazydocker
// (REA modul 5): satu stream aktif, dibatalkan saat seleksi berpindah.
func (m *Model) restartLogs() tea.Cmd {
m.stopLogs()
if m.view != viewList || m.section != sectionContainers || m.tab != tabLogs ||
m.cl == nil || m.sectionLen() == 0 {
return nil
}
c := m.containers[min(m.cur(), m.sectionLen()-1)]
m.logID++
id := m.logID
ch := make(chan string, 256)
m.logCh = ch
ctx, cancel := context.WithCancel(context.Background())
m.logCancel = cancel
cl, tail, ts := m.cl, m.tailLines, m.showTimestamps
go streamContainerLogs(ctx, cl, c.ID, tail, ts, ch)
return waitLogBatch(id, ch)
}
func (m *Model) cycleTab(delta int) tea.Cmd {
if m.view != viewList || m.section != sectionContainers {
return nil
}
m.tab = (m.tab + delta + tabCount) % tabCount
m.focus = focusContent
m.sound.Play(audio.Click)
return m.restartData()
}
// restartStats — bersihkan riwayat & mulai snapshot periodik (one-shot @1 dtk)
// bila konteksnya cocok (tab Statistik). Tidak ada stream persisten.
func (m *Model) restartStats() tea.Cmd {
m.statsID++
m.stats = nil
if m.view != viewList || m.section != sectionContainers || m.tab != tabStats ||
m.cl == nil || m.sectionLen() == 0 {
return nil
}
c := m.containers[min(m.cur(), m.sectionLen()-1)]
return fetchStats(m.statsID, m.cl, c.ID)
}
// restartData — restart semua sumber data detail (log + stats + inspect/env/
// proses) untuk seleksi saat ini; masing-masing menyaring sendiri konteksnya.
func (m *Model) restartData() tea.Cmd {
if m.section == sectionContainers && m.sectionLen() > 0 {
m.selID = m.containers[min(m.cur(), m.sectionLen()-1)].ID
}
return tea.Batch(
m.restartLogs(), m.restartStats(),
m.restartInspect(), m.restartEnv(), m.restartProcs(),
)
}
func (m *Model) detailCmdFor(tab int, fetch func(id int64, cl *docker.Client, cid string) tea.Cmd, id *int64) tea.Cmd {
*id++
if m.view != viewList || m.section != sectionContainers || m.tab != tab ||
m.cl == nil || m.sectionLen() == 0 {
return nil
}
c := m.containers[min(m.cur(), m.sectionLen()-1)]
return fetch(*id, m.cl, c.ID)
}
func (m *Model) restartInspect() tea.Cmd {
m.inspectJSON, m.inspectErr = nil, nil
return m.detailCmdFor(tabConfig, fetchInspect, &m.inspectID)
}
func (m *Model) restartEnv() tea.Cmd {
m.env, m.envErr = nil, nil
return m.detailCmdFor(tabEnv, fetchEnv, &m.envID)
}
func (m *Model) restartProcs() tea.Cmd {
m.procsTitle, m.procsRows, m.procsErr = nil, nil, nil
return m.detailCmdFor(tabTop, fetchProcs, &m.procsID)
}
// --- data helpers ---
func (m Model) lenOf(s section) int {
switch s {
case sectionStacks:
return len(m.stacks)
case sectionImages:
return len(m.images)
case sectionVolumes:
return len(m.volumes)
case sectionNetworks:
return len(m.networks)
default:
return len(m.containers)
}
}
func (m Model) sectionLen() int { return m.lenOf(m.section) }
func (m Model) cur() int { return m.cursors[m.section] }
func (m *Model) setCur(v int) { m.cursors[m.section] = v }
func (m Model) sectionName() string {
switch m.section {
case sectionStacks:
return i18n.T("section.stacks")
case sectionImages:
return i18n.T("section.images")
case sectionVolumes:
return i18n.T("section.volumes")
case sectionNetworks:
return i18n.T("section.networks")
default:
return i18n.T("section.containers")
}
}
func shortID(s string) string {
s = strings.TrimPrefix(s, "sha256:")
if len(s) > 12 {
s = s[:12]
}
return s
}
func (m Model) imageInUse(id string) bool {
for _, c := range m.containers {
if shortID(c.ImageID) == id {
return true
}
}
return false
}
func imageDisplayName(im docker.Image) string {
if len(im.Tags) > 0 {
return im.Tags[0]
}
return im.ID
}
// --- layout helpers ---
func (m Model) toastRows() int {
if m.toast.Active() {
return 5 // 4 baris toast + 1 baris jarak
}
return 0
}
// toolRows — baris toolbar workspace (1 di view daftar, 0 di beranda).
func (m Model) toolRows() int {
if m.view == viewList {
return 1
}
return 0
}
func (m Model) headerRows() int { return 1 + m.toolRows() + m.toastRows() }
func (m Model) bodyHeight() int {
h := m.height - 3 - m.toastRows() - m.toolRows() // header + toolbar + footer + margin
if h < 5 {
h = 5
}
return h
}
func (m Model) leftWidth() int {
w := int(float64(m.width) * 0.33)
if w < 24 {
w = 24
}
if w > 46 {
w = 46
}
if w > m.width-24 {
w = max(16, m.width-24)
}
return w
}
// scrollStart — offset window daftar agar kursor selalu terlihat.
func (m Model) scrollStart() int {
rows := m.bodyHeight() - 2 // konten dalam panel (tanpa border)
if rows <= 0 || m.cur() < rows {
return 0
}
return m.cur() - rows + 1
}
func stateStyle(state string) (glyph string, c theme.Color) {
th := theme.Current()
switch state {
case "running":
return "●", th.Success
case "paused":
return "⏸", th.Warning
case "restarting", "created":
return "◌", th.Info
case "exited", "dead":
return "○", th.Danger
default:
return "○", th.Overlay
}
}
// --- styling helpers (semua via token tema — PRD §5.4) ---
func dim(s string) string {
return theme.Style(theme.Current().Subtext).Render(s)
}
func text(s string) string {
return theme.Style(theme.Current().Text).Render(s)
}
func red(s string) string {
return theme.Style(theme.Current().Danger).Render(s)
}
func styled(c theme.Color, s string) string {
return theme.Style(c).Render(s)
}
func truncate(s string, w int) string {
r := []rune(s)
if w <= 1 || len(r) <= w {
return s
}
return string(r[:w-1]) + "…"
}
// field — label kolom (padding tetap agar nilai sejajar).
func field(label string) string { return fieldW(label, 7) }
func fieldW(label string, w int) string {
return text(fmt.Sprintf("%-*s", w, label))
}
// panel — kotak gaya mirador: judul di border atas (kiri), meta (kanan),
// border redup; konten otomatis dipangkas/padded selebar panel.
func panel(title, meta string, lines []string, w, h int, focused bool) string {
if w < 6 || h < 2 {
return ""
}
th := theme.Current()
bc := th.Overlay
if focused {
bc = th.Accent
}
border := lipgloss.NewStyle().Foreground(lipgloss.Color(string(bc)))
titleS := text(title)
left := border.Render("╭─ ") + titleS
right := border.Render("─╮")
if meta != "" {
right = " " + dim(meta) + border.Render(" ─╮")
}
gap := w - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 1 {
gap = 1
}
var b strings.Builder
b.WriteString(left)
b.WriteString(border.Render(strings.Repeat("─", gap)))
b.WriteString(right)
content := lipgloss.NewStyle().Width(w - 4)
for i := 0; i < h-2; i++ {
l := ""
if i < len(lines) {
l = lines[i]
}
b.WriteString("\n")
b.WriteString(border.Render("│"))
b.WriteString(" ")
b.WriteString(content.Render(ansiTruncate(l, w-4)))
b.WriteString(" ")
b.WriteString(border.Render("│"))
}
b.WriteString("\n")
b.WriteString(border.Render("╰" + strings.Repeat("─", w-2) + "╯"))
return b.String()
}
// --- views ---
func (m Model) View() tea.View {
header := theme.Style(theme.Current().Accent).Bold(true).
Render("tia — Terminal Is A My Way")
var body, footer string
switch m.view {
case viewHome:
header += dim(" · " + i18n.T("nav.home"))
body = m.homeBody()
default:
header += dim(" · " + strings.ToLower(m.sectionName()))
body = lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView())
}
footer = m.footerLine()
parts := []string{}
if t := m.toast.View(max(m.width, 20)); t != "" {
parts = append(parts, t)
}
parts = append(parts, header)
if m.view == viewList {
parts = append(parts, m.toolbarLine())
}
parts = append(parts, body, footer)
content := m.overlayModal(strings.Join(parts, "\n"))
v := tea.NewView(content)
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
func (m Model) footerPlain() string {
hints := i18n.T("hint.footerHome")
if m.view == viewList {
if m.section == sectionStacks {
hints = i18n.T("hint.footerStacks")
} else {
hints = i18n.T("hint.footerContainers")
}
}
anim := "—"
if m.animating {
anim = fmt.Sprintf("%d", m.fps)
}
full := i18n.Tf("footer.status", hints, anim, m.sound.Status())
if lipgloss.Width(full) > m.width {
// Hint aksi lebih penting daripada status diagnostik: buang status dulu.
full = truncate(hints, m.width)
}
return full
}
func (m Model) footerLine() string { return dim(m.footerPlain()) }
// mouseUI — hit-test elemen UI eksplisit terhadap mouse (F-022). Semua yang
// tampak seperti tombol/tab/hint HARUS sampai di sini.
// handled=true berarti klik dikonsumsi (walau tanpa aksi) supaya tidak jatuh
// ke pemilihan baris generik di bawahnya.
func (m *Model) mouseUI(x, y int) (tea.Cmd, bool) {
// 1) Hint footer: klik "[k]" (atau labelnya) menjalankan aksi k.
if y == m.height-2 {
if key, ok := m.footerKeyAt(x); ok {
return m.handleKey(key), true
}
return nil, false
}
// 2) Toolbar workspace (baris di bawah header): pill cari + tombol stack.
if m.view == viewList && y == m.headerRows()-1 {
return m.toolbarKeyAt(x), true
}
// 3) Tab bar detail (view kontainers).
if m.view == viewList && m.section == sectionContainers && m.sectionLen() > 0 {
if idx, ok := m.tabAt(x, y); ok {
m.tab = idx
m.focus = focusContent
m.sound.Play(audio.Click)
return m.restartData(), true
}
// 3b) Kolom aksi tab (paling kanan): baris = item aksi tab aktif.
if _, wAct := m.detailWidths(); wAct > 0 && x >= m.width-wAct {
if row := y - (m.headerRows() + 1); row >= 0 {
items := m.tabActions()
if row < len(items) {
return m.handleKey(items[row][0]), true
}
}
return nil, true
}
}
// 4) Toolbar aksi / tombol konfirmasi stack (baris konten pertama detail).
if m.view == viewList && m.section == sectionStacks && m.sectionLen() > 0 && y == m.headerRows()+1 {
line := ""
switch {
case m.stackConfirm != "":
line = m.stackConfirmLine()
case m.stackBusy == "":
line = m.stackToolbar()
}
if line != "" {
if key, ok := keyZonesAt(line, x-m.leftWidth()-2); ok {
return m.handleKey(key), true
}
}
}
// 5) Panel beranda (baris kontainer, panel Docker, CTA masuk workspace).
if m.view == viewHome {
if cmd := m.homeClick(x, y); cmd != nil {
return cmd, true
}
}
return nil, false
}
// toolbarPlain — toolbar workspace tanpa gaya warna (sumber koordinat hit-test).
func (m Model) toolbarPlain() string {
search := "/ " + i18n.T("toolbar.search")
if m.searching {
search = "/ " + m.query + "▌"
}
left := "[ " + search + " ]"
right := "[ " + i18n.T("toolbar.addStack") + " ] [ " + i18n.T("toolbar.convert") + " ]"
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 2 {
return ansiTruncate(left, m.width)
}
return left + strings.Repeat(" ", gap) + right
}
func (m Model) toolbarLine() string {
th := theme.Current()
search := "/ " + i18n.T("toolbar.search")
if m.searching {
search = "/ " + m.query + "▌"
}
left := theme.Style(th.Accent).Render("[ " + search + " ]")
right := theme.Style(th.Accent).Render(
"[ " + i18n.T("toolbar.addStack") + " ] [ " + i18n.T("toolbar.convert") + " ]")
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 2 {
return ansiTruncate(left, m.width)
}
return left + strings.Repeat(" ", gap) + right
}
// toolbarKeyAt — klik toolbar workspace: pill Cari / tombol stack.
func (m *Model) toolbarKeyAt(x int) tea.Cmd {
label, ok := labelZoneAt(m.toolbarPlain(), x)
if !ok {
return nil
}
switch label {
case "/ " + i18n.T("toolbar.search"):
m.searching, m.query = true, ""
m.focus = focusSidebar
m.sound.Play(audio.Click)
// shortcut: "+ stack" untuk kini = modal konverter (jalur buat stack yang
// ada); ganti saat modal add-stack (editor stack) dibangun.
case i18n.T("toolbar.addStack"), i18n.T("toolbar.convert"):
return m.handleKey("c")
}
return nil
}
// searchKeyMsg — mode cari daftar: ketik = lompat ke item yang cocok.
// Esc batal (bersihkan), Enter bertahan di pilihan, Ctrl+C keluar aplikasi.
func (m *Model) searchKeyMsg(msg tea.KeyPressMsg) tea.Cmd {
switch {
case msg.Code == 'c' && msg.Mod&tea.ModCtrl != 0:
m.searching = false
m.stopLogs()
if m.eventsCancel != nil {
m.eventsCancel()
}
m.sound.Play(audio.ModalClose)
return tea.Quit
case msg.Code == tea.KeyEsc:
m.searching, m.query = false, ""
m.sound.Play(audio.ModalClose)
case msg.Code == tea.KeyEnter:
m.searching = false
m.sound.Play(audio.Click)
case msg.Code == tea.KeyBackspace:
if r := []rune(m.query); len(r) > 0 {
m.query = string(r[:len(r)-1])
}
return m.searchJumpCmd()
default:
if msg.Text != "" {
m.query += msg.Text
return m.searchJumpCmd()
}
}
return nil
}
// searchJumpCmd — kursor ke item pertama yang namanya memuat query (cari-lompat,
// bukan filter: daftar tetap utuh).
func (m *Model) searchJumpCmd() tea.Cmd {
q := strings.ToLower(strings.TrimSpace(m.query))
if q == "" || m.sectionLen() == 0 {
return nil
}
for i := 0; i < m.sectionLen(); i++ {
if strings.Contains(strings.ToLower(m.itemName(i)), q) {
if i == m.cur() {
return nil
}
m.setCur(i)
m.sound.Play(audio.Navigate)
return m.restartData()
}
}
return nil
}
// itemName — nama tampil item indeks i (dasar pencarian & pencocokan).
func (m Model) itemName(i int) string {
switch m.section {
case sectionStacks:
return m.stacks[i].Name
case sectionImages:
return imageDisplayName(m.images[i])
case sectionVolumes:
return m.volumes[i].Name
case sectionNetworks:
return m.networks[i].Name
default:
return m.containers[i].Name
}
}
// detailWidths — lebar panel detail kontainer & kolom aksi tab (0 = tak tampil
// di layar sempit, agar tab bar tetap utuh).
func (m Model) detailWidths() (wDetail, wAct int) {
w := m.width - m.leftWidth()
if m.section == sectionContainers && w >= 66 {
wAct = min(max(int(float64(w)*0.22), 15), 20)
}
return w - wAct, wAct
}
// tabActions — aksi kontekstual tab aktif (kolom kanan, mock "TAB ACTION").
func (m Model) tabActions() [][2]string {
switch m.tab {
case tabLogs:
return [][2]string{{"S", i18n.T("tabaction.timestamp")}, {"G", i18n.T("tabaction.follow")}}
case tabConfig, tabEnv:
return [][2]string{{"G", i18n.T("tabaction.bottom")}}
default:
return [][2]string{{"r", i18n.T("tabaction.reload")}}
}
}
// tabActionLines — baris kolom aksi tab; tinggi WAJIB sama alokasi panel
// (hit-test per-baris bergantung padanya).
func (m Model) tabActionLines(w, rows int) []string {
items := m.tabActions()
out := make([]string, 0, rows)
for _, it := range items {
out = append(out, theme.Style(theme.Current().Accent).Render("["+it[0]+"]")+" "+
text(truncate(it[1], w)))
}
for len(out) < rows {
out = append(out, "")
}
if len(out) > rows {
out = out[:rows]
}
return out
}
// footerKeyAt — posisi rune x berada di segmen hint mana; kembalikan tombolnya.
func (m *Model) footerKeyAt(x int) (string, bool) { return keyZonesAt(m.footerPlain(), x) }
// keyZonesAt — cari segmen "[k] …" pada satu baris; posisi rune x → tombol k.
// Dipakai bersama oleh hint footer, toolbar stack, dan tombol konfirmasi.
func keyZonesAt(line string, x int) (string, bool) {
runes := []rune(line)
for i := 0; i < len(runes); i++ {
if runes[i] != '[' {
continue
}
j := i + 1
for j < len(runes) && runes[j] != ']' {
j++
}
if j >= len(runes) {
return "", false
}
end := j + 1
for end < len(runes) && runes[end] != '·' && runes[end] != '[' {
end++ // segmen: sampai pemisah "·" ATAU bracket tombol berikutnya
}
if x >= i && x < end {
k := strings.Split(string(runes[i+1:j]), "/")[0]
if dash := strings.IndexByte(k, '-'); dash > 0 {
k = k[:dash] // "1-5" → "1"
}
if strings.EqualFold(k, "tab") {
k = "tab"
}
return k, true
}
i = j
}
return "", false
}
// tabAt — posisi klik pada baris tab bar → indeks tab.
func (m Model) tabAt(x, y int) (int, bool) {
if y != m.headerRows()+1 { // baris konten pertama = tab bar
return 0, false
}
xl := x - (m.leftWidth() + 2)
if xl < 0 {
return 0, false
}
labels := []string{
i18n.T("tab.logs"), i18n.T("tab.stats"), i18n.T("tab.config"),
i18n.T("tab.env"), i18n.T("tab.top"),
}
pos := 0
for i, l := range labels {
w := len([]rune(l))
if i == m.tab {
w += 2 // tanda aktif: [Label]
}
if xl >= pos && xl < pos+w {
return i, true
}
pos += w + 2 // pemisah " "
}
return 0, false
}
func (m Model) sidebarView() string {
w, h := m.leftWidth(), m.bodyHeight()
title := m.sectionName()
meta := fmt.Sprintf("(%d)", m.sectionLen())
if m.loading {
meta = i18n.T("state.loading")
}
var lines []string
switch {
case m.loadErr != nil:
lines = append(lines, red(i18n.T("error.loadFailed")), dim(i18n.T("hint.retry")))
case m.loading && m.sectionLen() == 0:
lines = append(lines, dim(i18n.T("state.loading")))
case m.sectionLen() == 0:
lines = append(lines, dim(i18n.Tf("hint.emptyItems", title)))
default:
rows := h - 2
start := m.scrollStart()
for i := start; i < m.sectionLen() && i-start < rows; i++ {
lines = append(lines, m.sectionLine(i, w-4))
}
}
return panel(title, meta, lines, w, h, m.focus == focusSidebar)
}
func (m Model) sectionLine(i, innerW int) string {
sel := i == m.cur()
th := theme.Current()
var line string
switch m.section {
case sectionStacks:
s := m.stacks[i]
glyph, color := stackStyle(s.Status)
line = paintRow(sel, color, glyph) + paintRow(sel, th.Text, " "+truncate(s.Name, innerW-4))
case sectionImages:
im := m.images[i]
glyph, color := "○", th.Overlay
if m.imageInUse(im.ID) {
glyph, color = "●", th.Success
}
line = paintRow(sel, color, glyph) +
paintRow(sel, th.Text, " "+truncate(imageDisplayName(im), innerW-10)) +
paintRow(sel, th.Subtext, " "+humanBytes(im.Size))
case sectionVolumes:
v := m.volumes[i]
line = paintRow(sel, th.Text, truncate(v.Name, innerW-6)) +
paintRow(sel, th.Subtext, " "+v.Driver)
case sectionNetworks:
n := m.networks[i]
line = paintRow(sel, th.Text, truncate(n.Name, innerW-6)) +
paintRow(sel, th.Subtext, " "+n.Driver)
default:
c := m.containers[i]
glyph, color := stateStyle(c.State)
line = paintRow(sel, color, glyph) +
paintRow(sel, th.Text, " "+truncate(c.Name, innerW-4))
}
if sel {
line = selPad(line, innerW)
}
return line
}
// paintRow — satu segmen baris daftar; baris terpilih memakai latar
// token Selection sehingga posisi aktif jelas tanpa perlu panah.
func paintRow(sel bool, fg theme.Color, s string) string {
st := theme.Style(fg)
if sel {
st = st.Background(lipgloss.Color(string(theme.Current().Selection)))
}
return st.Render(s)
}
// selPad — isi sisa lebar baris terpilih dengan latar Selection penuh.
func selPad(s string, w int) string {
gap := w - lipgloss.Width(s)
if gap <= 0 {
return s
}
bg := lipgloss.NewStyle().Background(lipgloss.Color(string(theme.Current().Selection)))
return s + bg.Render(strings.Repeat(" ", gap))
}
func (m Model) contentView() string {
w, h := m.width-m.leftWidth(), m.bodyHeight()
focused := m.view == viewList && m.focus == focusContent
if m.loadErr != nil {
return panel(m.sectionName(), "", []string{
red(i18n.Tf("error.loadSection", m.sectionName())), "",
dim(truncate(m.loadErr.Error(), w-6)),
}, w, h, focused)
}
if m.sectionLen() == 0 {
return panel(m.sectionName(), "", []string{
dim(i18n.Tf("hint.emptyItems", m.sectionName())),
}, w, h, focused)
}
idx := min(m.cur(), m.sectionLen()-1)
// Stack: ringkasan + toolbar aksi (klikable; aksi async + refresh).
if m.section == sectionStacks {
if m.stacksErr != nil {
return panel(m.sectionName(), "", []string{red(truncate(m.stacksErr.Error(), w-6))}, w, h, focused)
}
s := m.stacks[idx]
glyph, color := stackStyle(s.Status)
lines := []string{}
switch {
case m.stackConfirm == s.Name:
lines = append(lines, red(i18n.Tf("stack.confirmDown", s.Name)), m.stackConfirmLine(), "")
case m.stackBusy == s.Name:
lines = append(lines, dim(i18n.Tf("stack.busy", i18n.T("stack."+mapActionName(m.stackBusyAct)))), "")
default:
lines = append(lines, m.stackToolbar(), "")
}
lines = append(lines,
field(i18n.T("field.dir"))+dim(truncate(s.Dir, w-12)),
field(i18n.T("field.composeFile"))+dim(truncate(s.ComposeFile, w-18)),
field(i18n.T("field.status"))+styled(color, glyph+" "+stackStatusText(s.Status)),
"",
dim(i18n.T("stack.mouseHint")),
)
return panel(s.Name, "", lines, w, h, focused)
}
// Kontainer: tab detail + kolom aksi tab di kanan (mock "TAB ACTION").
if m.section == sectionContainers {
c := m.containers[idx]
wDetail, wAct := m.detailWidths()
lines := []string{m.tabBarLine(wDetail - 4)}
switch m.tab {
case tabLogs:
lines = append(lines, m.logLines(h-3)...)
case tabStats:
lines = append(lines, m.statsLines(h-3, wDetail-4)...)
case tabConfig:
lines = append(lines, m.inspectLines(h-3, wDetail-4)...)
case tabEnv:
lines = append(lines, m.envLines(h-3, wDetail-4)...)
case tabTop:
lines = append(lines, m.procLines(h-3, wDetail-4)...)
default:
lines = append(lines, "", dim(i18n.T("hint.tabComing")))
}
detail := panel(c.Name, c.State, lines, wDetail, h, focused)
if wAct == 0 {
return detail
}
return lipgloss.JoinHorizontal(lipgloss.Top, detail,
panel(i18n.T("tab.action"), "", m.tabActionLines(wAct-4, h-2), wAct, h, focused))
}
title, meta := m.sectionName(), ""
var lines []string
switch m.section {
case sectionImages:
im := m.images[idx]
title, meta = imageDisplayName(im), humanBytes(im.Size)
tags := strings.Join(im.Tags, ", ")
if tags == "" {
tags = i18n.T("state.untagged")
}
inUse := i18n.T("state.no")
if m.imageInUse(im.ID) {
inUse = i18n.T("state.yes")
}
lines = append(lines,
field(i18n.T("field.id"))+dim(im.ID),
field(i18n.T("field.tags"))+dim(truncate(tags, w-12)),
field(i18n.T("field.size"))+dim(humanBytes(im.Size)),
field(i18n.T("field.created"))+dim(time.Unix(im.Created, 0).Format("02 Jan 2006")),
field(i18n.T("field.inUse"))+dim(inUse),
)
case sectionVolumes:
v := m.volumes[idx]
title, meta = v.Name, v.Driver
lines = append(lines,
field(i18n.T("field.driver"))+dim(v.Driver),
field(i18n.T("field.mountpoint"))+dim(truncate(v.Mountpoint, w-14)),
)
case sectionNetworks:
n := m.networks[idx]
title, meta = n.Name, n.Driver
lines = append(lines,
field(i18n.T("field.id"))+dim(n.ID),
field(i18n.T("field.driver"))+dim(n.Driver),
field(i18n.T("field.scope"))+dim(n.Scope),
field(i18n.T("field.subnet"))+dim(n.Subnet),
)
}
return panel(title, meta, lines, w, h, focused)
}
// tabBarLine — baris tab (aktif bergaris aksen, sisanya redup).
func (m Model) tabBarLine(w int) string {
tabs := []string{
i18n.T("tab.logs"), i18n.T("tab.stats"), i18n.T("tab.config"),
i18n.T("tab.env"), i18n.T("tab.top"),
}
parts := make([]string, 0, len(tabs))
for i, t := range tabs {
if i == m.tab {
parts = append(parts, theme.Style(theme.Current().Accent).Bold(true).Render("["+t+"]"))
} else {
parts = append(parts, dim(t))
}
}
return ansiTruncate(strings.Join(parts, " "), w)
}
// logLines — potongan log yang tampil: h-3 baris, offset dari ekor (live).
func (m Model) logLines(rows int) []string {
if len(m.logs) == 0 {
msg := i18n.T("hint.logsWaiting")
if m.logEOF {
msg = i18n.T("hint.logsEnd")
}
return []string{dim(msg)}
}
end := len(m.logs) - m.logOffset
if end > len(m.logs) {
end = len(m.logs)
}
if end < 0 {
end = 0
}
start := end - rows
if start < 0 {
start = 0
}
out := make([]string, 0, end-start)
for _, l := range m.logs[start:end] {
out = append(out, colorizeLog(l))
}
return out
}
// detailScrollMax — batas scroll dokumen (tab Config/Env) dalam baris.
func (m Model) detailScrollMax() int {
rows := m.bodyHeight() - 3
var total int
switch m.tab {
case tabConfig:
if len(m.inspectJSON) > 0 {
total = bytes.Count(m.inspectJSON, []byte("\n")) + 1
}
case tabEnv:
total = len(m.env)
}
if total <= rows {
return 0
}
return total - rows
}
// inspectLines — JSON inspect mentah (scroll via detailScroll).
func (m Model) inspectLines(rows, w int) []string {
if m.inspectErr != nil {
return []string{red(truncate(m.inspectErr.Error(), w))}
}
if len(m.inspectJSON) == 0 {
return []string{dim(i18n.T("hint.inspectWaiting"))}
}
all := strings.Split(string(m.inspectJSON), "\n")
out := make([]string, 0, rows)
for i := m.detailScroll; i < len(all) && len(out) < rows; i++ {
out = append(out, all[i])
}
return out
}
// envLines — KEY=value; kunci terang, nilai redup.
func (m Model) envLines(rows, w int) []string {
if m.envErr != nil {
return []string{red(truncate(m.envErr.Error(), w))}
}
if m.env == nil {
return []string{dim(i18n.T("hint.inspectWaiting"))}
}
out := make([]string, 0, rows)
for i := m.detailScroll; i < len(m.env) && len(out) < rows; i++ {
kv := m.env[i]
if k, v, ok := strings.Cut(kv, "="); ok {
out = append(out, text(k+"=")+dim(truncate(v, max(1, w-len([]rune(k))-1))))
} else {
out = append(out, text(kv))
}
}
return out
}
// procLines — tabel proses (`docker top`): header redup, kolom dipadatkan,
// kolom terakhir (perintah) mengambil sisa lebar.
func (m Model) procLines(rows, w int) []string {
if m.procsErr != nil {
return []string{red(truncate(m.procsErr.Error(), w))}
}
cols := len(m.procsTitle)
if cols == 0 {
return []string{dim(i18n.T("hint.procsWaiting"))}
}
widths := make([]int, cols)
for i, t := range m.procsTitle {
widths[i] = len([]rune(t))
}
for _, r := range m.procsRows {
for i, c := range r {
if i < cols && len([]rune(c)) > widths[i] {
widths[i] = len([]rune(c))
}
}
}
for i := 0; i < cols-1; i++ {
if widths[i] > 14 {
widths[i] = 14
}
}
used := 0
for i := 0; i < cols-1; i++ {
used += widths[i] + 2
}
widths[cols-1] = max(10, w-used)
row := func(cells []string, style func(string) string) string {
var b strings.Builder
for i := 0; i < cols; i++ {
c := ""
if i < len(cells) {
c = cells[i]
}
if r := []rune(c); len(r) > widths[i] {
c = string(r[:max(1, widths[i]-1)]) + "…"
}
b.WriteString(style(fmt.Sprintf("%-*s", widths[i], c)))
if i < cols-1 {
b.WriteString(" ")
}
}
return b.String()
}
out := []string{row(m.procsTitle, dim)}
maxRows := rows - 1
for i, r := range m.procsRows {
if i >= maxRows {
out = append(out, dim(fmt.Sprintf("… +%d", len(m.procsRows)-i)))
break
}
out = append(out, row(r, text))
}
return out
}
// logLevelRE — token level umum di log (INFO/WARN/ERRO/…).
var logLevelRE = regexp.MustCompile(`\b(TRACE|TRAC|DEBUG|DEBU|INFO|WARN|WARNING|ERRO|ERROR|FATAL|FATA)\b`)
// statsLines — isi tab Statistik (PRD §5.1.1): bar CPU/RAM, riwayat CPU
// 60 detik sebagai sparkline, laju NET/Disk, dan PIDs.
func (m Model) statsLines(rows, w int) []string {
if len(m.stats) == 0 {
return []string{dim(i18n.T("hint.statsWaiting"))}
}
cur := m.stats[len(m.stats)-1]
out := []string{
fieldW("CPU", 6) + pctBar(cur.CPUPercent, 20) + dim(fmt.Sprintf(" %5.1f%%", cur.CPUPercent)),
fieldW("RAM", 6) + pctBar(cur.MemPercent, 20) +
dim(fmt.Sprintf(" %s / %s", humanKBs(cur.MemUsed/1024), humanKBs(cur.MemLimit/1024))),
"",
dim(i18n.T("stats.history")),
}
vals := make([]float64, 0, len(m.stats))
for _, s := range m.stats {
vals = append(vals, s.CPUPercent)
}
out = append(out, theme.Style(theme.Current().Accent).Render(components.Sparkline(vals, min(w, statsHistory))), "")
if len(m.stats) >= 2 {
prev := m.stats[len(m.stats)-2]
dt := cur.At.Sub(prev.At).Seconds()
if dt <= 0 {
dt = 1
}
rate := func(curV, prevV uint64) uint64 {
if curV < prevV { // counter reset (kontainer restart)
return 0
}
return uint64(float64(curV-prevV) / dt)
}
out = append(out,
text("NET ")+dim("↑ "+humanBytes(int64(rate(cur.NetRx, prev.NetRx)))+"/s ↓ "+humanBytes(int64(rate(cur.NetTx, prev.NetTx)))+"/s"),
text("Disk ")+dim(i18n.T("stats.read")+" "+humanBytes(int64(rate(cur.BlkRead, prev.BlkRead)))+"/s · "+
i18n.T("stats.write")+" "+humanBytes(int64(rate(cur.BlkWrite, prev.BlkWrite)))+"/s"),
)
}
out = append(out, text("PIDs ")+dim(fmt.Sprintf("%d", cur.PIDs)))
if len(out) > rows {
out = out[:rows]
}
return out
}
// colorizeLog — warnai token level (PRD §5.4: INFO=info, WARN=warning, ERRO=danger).
func colorizeLog(line string) string {
th := theme.Current()
return logLevelRE.ReplaceAllStringFunc(line, func(tok string) string {
var c theme.Color
switch {
case strings.HasPrefix(tok, "INFO"):
c = th.Info
case strings.HasPrefix(tok, "WARN"):
c = th.Warning
case strings.HasPrefix(tok, "TRAC"), strings.HasPrefix(tok, "DEBU"):
c = th.Subtext
default:
c = th.Danger
}
return theme.Style(c).Bold(true).Render(tok)
})
}
// handleKey — SATU jalur aksi, dipakai keyboard DAN klik mouse
// (tab bar, hint footer, baris beranda). Tambah aksi = tambah case di sini.
func (m *Model) handleKey(key string) tea.Cmd {
// Konfirmasi destruktif (turunkan stack) menangkap input lebih dulu.
if m.stackConfirm != "" {
switch key {
case "y":
name := m.stackConfirm
m.stackConfirm = ""
for i, s := range m.stacks {
if s.Name == name {
m.setCur(i)
return m.beginStackAction("D")
}
}
case "n", "esc":
m.stackConfirm = ""
m.sound.Play(audio.ModalClose)
}
return nil
}
switch key {
case "q", "ctrl+c":
m.stopLogs()
if m.eventsCancel != nil {
m.eventsCancel()
}
m.sound.Play(audio.ModalClose)
return tea.Quit
case "esc":
if m.view == viewHome {
m.sound.Play(audio.ModalClose)
return tea.Quit
}
if m.focus == focusContent {
m.focus = focusSidebar
m.sound.Play(audio.Click)
break
}
m.view = viewHome
m.stopLogs()
m.sound.Play(audio.Click)
case "0":
m.view = viewHome
m.stopLogs()
m.sound.Play(audio.Click)
case "1", "enter":
m.jumpSection(sectionStacks)
m.sound.Play(audio.Click)
return m.restartData()
case "2":
m.jumpSection(sectionContainers)
m.sound.Play(audio.Click)
return m.restartData()
case "3":
m.jumpSection(sectionImages)
m.sound.Play(audio.Click)
return m.restartData()
case "4":
m.jumpSection(sectionVolumes)
m.sound.Play(audio.Click)
return m.restartData()
case "5":
m.jumpSection(sectionNetworks)
m.sound.Play(audio.Click)
return m.restartData()
case "a": // reduced motion
m.animating = !m.animating
if m.animating {
m.tickID = animation.NewID()
return animation.Next(m.tickID)
}
case "r": // muat ulang semua data docker + stream event
if m.cl == nil {
break
}
m.loading = true
m.sound.Play(audio.Click)
cmds := append(loadAll(m.cl), m.restartData())
cmds = append(cmds, m.startEvents())
m.stacksID++
cmds = append(cmds, fetchStacks(m.stacksID, m.compose, m.stacksDir))
return tea.Batch(cmds...)
case "T": // tombol cepat tema (PRD §5.4): auto → dark → light → terminal
cur := theme.Current()
next := theme.CycleMode(cur.Mode)
theme.Set(next, cur.AccentName)
m.toast.Show(components.LevelInfo,
i18n.Tf("toast.themeMode", i18n.T("theme."+string(next))),
i18n.T("theme.switchHint"))
m.sound.Play(audio.Click)
case "t": // demo toast (sampai modal asli datang)
m.toast.Show(components.LevelSuccess, i18n.T("toast.demoTitle"), i18n.T("toast.demoBody"))
m.sound.Play(audio.NotifySuccess)
case "tab":
if m.view == viewList {
if m.focus == focusSidebar {
m.focus = focusContent
} else {
m.focus = focusSidebar
}
m.sound.Play(audio.Click)
}
case "h", "left", "[":
if m.view == viewList && m.section == sectionContainers {
return m.cycleTab(-1)
}
case "l", "right", "]":
if m.view == viewList && m.section == sectionContainers {
return m.cycleTab(1)
}
case "c": // modal konverter docker run → compose (PRD §5.1.3)
m.modal = newConverterModal()
m.sound.Play(audio.ModalOpen)
case "/": // mode cari daftar (paritas keyboard pill "Cari" di toolbar)
if m.view == viewList {
m.searching, m.query = true, ""
m.focus = focusSidebar
m.sound.Play(audio.Click)
}
case "u", "s", "R", "p":
if m.view == viewList && m.section == sectionStacks {
return m.beginStackAction(key)
}
case "D":
if m.view == viewList && m.section == sectionStacks && m.sectionLen() > 0 && m.stackBusy == "" {
s := m.stacks[min(m.cur(), m.sectionLen()-1)]
m.stackConfirm = s.Name
m.sound.Play(audio.NotifyWarning)
}
case "G": // ekor log (live) / dasar dokumen (config & env)
if m.view == viewList && m.focus == focusContent {
switch m.tab {
case tabConfig, tabEnv:
m.detailScroll = m.detailScrollMax()
default:
m.logOffset = 0
}
m.sound.Play(audio.Click)
}
case "S": // toggle timestamp log
m.showTimestamps = !m.showTimestamps
label := i18n.T("state.off")
if m.showTimestamps {
label = i18n.T("state.on")
}
m.toast.Show(components.LevelInfo, i18n.Tf("toast.timestamps", label), i18n.T("theme.switchHint"))
m.sound.Play(audio.Click)
return m.restartData()
case "j", "down":
if m.view == viewList && m.focus == focusContent {
switch m.tab {
case tabConfig, tabEnv:
if m.detailScroll < m.detailScrollMax() {
m.detailScroll++
}
default: // tab Log
if m.logOffset > 0 {
m.logOffset--
}
}
m.sound.Play(audio.Navigate)
break
}
if c := m.cur(); c < m.sectionLen()-1 {
m.setCur(c + 1)
}
m.sound.Play(audio.Navigate)
return m.restartData()
case "k", "up":
if m.view == viewList && m.focus == focusContent {
switch m.tab {
case tabConfig, tabEnv:
if m.detailScroll > 0 {
m.detailScroll--
}
default: // tab Log
if m.logOffset < len(m.logs)-1 {
m.logOffset++
}
}
m.sound.Play(audio.Navigate)
break
}
if c := m.cur(); c > 0 {
m.setCur(c - 1)
}
m.sound.Play(audio.Navigate)
return m.restartData()
}
return nil
}