Files
tia-love/internal/app/app.go
T

715 lines
17 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 (
"context"
"fmt"
"os"
"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/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
)
// section — bagian resource di workspace (PRD F-001: 1-4 jump to section).
type section int
const (
sectionContainers section = iota
sectionImages
sectionVolumes
sectionNetworks
sectionCount = 4
)
// --- 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
hostname string
sys sysinfo.Stats
cl *docker.Client
engine docker.Info
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, err := docker.New()
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: sectionContainers,
hostname: host,
sys: sysinfo.Read(),
cl: cl,
loading: true,
}
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
}
return m
}
func (m Model) Init() tea.Cmd {
cmds := []tea.Cmd{tea.RequestBackgroundColor} // untuk mode tema "auto"
if m.animating {
cmds = append(cmds, animation.Next(m.tickID))
}
if m.cl != nil {
cmds = append(cmds, loadAll(m.cl)...)
}
return tea.Batch(cmds...)
}
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:
return m.applyList(msg.err, func(mm *Model) { mm.containers = msg.items })
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 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:
switch msg.String() {
case "q", "ctrl+c":
m.sound.Play(audio.ModalClose)
return m, tea.Quit
case "esc":
if m.view == viewHome {
m.sound.Play(audio.ModalClose)
return m, tea.Quit
}
m.view = viewHome
m.sound.Play(audio.Click)
case "0":
m.view = viewHome
m.sound.Play(audio.Click)
case "1", "enter":
m.jumpSection(sectionContainers)
m.sound.Play(audio.Click)
case "2":
m.jumpSection(sectionImages)
m.sound.Play(audio.Click)
case "3":
m.jumpSection(sectionVolumes)
m.sound.Play(audio.Click)
case "4":
m.jumpSection(sectionNetworks)
m.sound.Play(audio.Click)
case "a": // reduced motion
m.animating = !m.animating
if m.animating {
m.tickID = animation.NewID()
return m, animation.Next(m.tickID)
}
case "r": // muat ulang semua data docker
if m.cl == nil {
break
}
m.loading = true
m.sound.Play(audio.Click)
return m, tea.Batch(loadAll(m.cl)...)
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 "j", "down":
if c := m.cur(); c < m.sectionLen()-1 {
m.setCur(c + 1)
}
m.sound.Play(audio.Navigate)
case "k", "up":
if c := m.cur(); c > 0 {
m.setCur(c - 1)
}
m.sound.Play(audio.Navigate)
}
case tea.MouseClickMsg:
if msg.Button == tea.MouseLeft {
m.sound.Play(audio.Click)
if m.toast.Active() {
m.toast = components.NewToast() // klik = tutup toast
}
// hit-test sidebar (hanya di view list):
// baris item pertama = headerRows + 1 (border atas panel)
if m.view == viewList && msg.X < m.leftWidth() && m.sectionLen() > 0 {
idx := msg.Y - (m.headerRows() + 1) + m.scrollStart()
if idx >= 0 && idx < m.sectionLen() {
m.setCur(idx)
}
}
}
}
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
}
// --- data helpers ---
func (m Model) lenOf(s section) int {
switch s {
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 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
}
func (m Model) headerRows() int { return 1 + m.toastRows() }
func (m Model) bodyHeight() int {
h := m.height - 3 - m.toastRows() // header(1) + footer(1) + margin(1)
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()
footer = m.footerLine(i18n.T("hint.footerHome"))
default:
header += dim(" · " + i18n.T("nav.containers"))
body = lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView())
footer = m.footerLine(i18n.T("hint.footerContainers"))
}
parts := []string{}
if t := m.toast.View(max(m.width, 20)); t != "" {
parts = append(parts, t)
}
parts = append(parts, header, body, footer)
v := tea.NewView(strings.Join(parts, "\n"))
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
func (m Model) footerLine(hints string) string {
anim := "—"
if m.animating {
anim = fmt.Sprintf("%d", m.fps)
}
return dim(truncate(i18n.Tf("footer.status", hints, anim, m.sound.Status()), m.width))
}
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, false)
}
func (m Model) sectionLine(i, innerW int) string {
mark := " "
if i == m.cur() {
mark = styled(theme.Current().Accent, "▶ ")
}
th := theme.Current()
switch m.section {
case sectionImages:
im := m.images[i]
glyph, color := "○", th.Overlay
if m.imageInUse(im.ID) {
glyph, color = "●", th.Success
}
return mark + styled(color, glyph) + " " +
text(truncate(imageDisplayName(im), innerW-10)) + dim(" "+humanBytes(im.Size))
case sectionVolumes:
v := m.volumes[i]
return mark + text(truncate(v.Name, innerW-6)) + dim(" "+v.Driver)
case sectionNetworks:
n := m.networks[i]
return mark + text(truncate(n.Name, innerW-6)) + dim(" "+n.Driver)
default:
c := m.containers[i]
glyph, color := stateStyle(c.State)
return mark + styled(color, glyph) + " " + text(truncate(c.Name, innerW-4))
}
}
func (m Model) contentView() string {
w, h := m.width-m.leftWidth(), m.bodyHeight()
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, false)
}
if m.sectionLen() == 0 {
return panel(m.sectionName(), "", []string{
dim(i18n.Tf("hint.emptyItems", m.sectionName())),
}, w, h, false)
}
idx := min(m.cur(), m.sectionLen()-1)
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),
)
default:
c := m.containers[idx]
glyph, color := stateStyle(c.State)
title, meta = c.Name, c.State
lines = append(lines,
field(i18n.T("field.id"))+dim(c.ID),
field(i18n.T("field.image"))+dim(truncate(c.Image, w-20)),
field(i18n.T("field.state"))+styled(color, glyph+" "+i18n.TOr("state."+c.State, c.State)),
field(i18n.T("field.status"))+dim(truncate(c.Status, w-20)),
"",
dim(i18n.T("hint.tabsComing")),
)
}
return panel(title, meta, lines, w, h, false)
}