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

451 lines
11 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/docker"
"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"
)
type viewKind int
const (
viewHome viewKind = iota
viewContainers
)
// containersMsg — hasil muat daftar kontainer (async via tea.Cmd).
type containersMsg struct {
items []docker.Container
err error
}
// infoMsg — ringkasan daemon docker.
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
hostname string
sys sysinfo.Stats
cl *docker.Client
engine docker.Info
engineErr error
containers []docker.Container
cursor int
loadErr error
loading bool
}
func New() Model {
cl, err := docker.New()
host, _ := os.Hostname()
m := Model{
sound: audio.New(audio.Options{Enabled: true, Volume: 70, MuteOnSSH: true}),
tickID: animation.NewID(),
animating: true,
toast: components.NewToast(),
view: viewHome,
hostname: host,
sys: sysinfo.Read(),
cl: cl,
loading: true,
}
if err != nil {
m.loadErr = err
m.engineErr = err
m.loading = false
}
return m
}
func (m Model) Init() tea.Cmd {
if m.cl == nil {
return animation.Next(m.tickID)
}
return tea.Batch(animation.Next(m.tickID), loadContainers(m.cl), loadInfo(m.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 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:
m.loading = false
if msg.err != nil {
m.loadErr = msg.err
m.toast.Show("✖ [ GAGAL ] Docker", msg.err.Error())
m.sound.Play(audio.NotifyError)
} else {
m.loadErr = nil
m.containers = msg.items
if m.cursor >= len(m.containers) {
m.cursor = max(0, len(m.containers)-1)
}
return m, loadInfo(m.cl) // sinkronkan hitungan di dashboard
}
return m, nil
case infoMsg:
if msg.err != nil {
m.engineErr = msg.err
} else {
m.engineErr = nil
m.engine = msg.info
}
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.view = viewContainers
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 data docker
if m.cl == nil {
break
}
m.loading = true
m.sound.Play(audio.Click)
return m, tea.Batch(loadContainers(m.cl), loadInfo(m.cl))
case "t": // demo toast (sampai modal asli datang)
m.toast.Show("✔ [ SUKSES ] Konversi Berhasil Disimpan",
"Stack 'web-app' tersimpan di ~/.tia/stacks/web-app/")
m.sound.Play(audio.NotifySuccess)
case "j", "down":
if len(m.containers) > 0 && m.cursor < len(m.containers)-1 {
m.cursor++
}
m.sound.Play(audio.Navigate)
case "k", "up":
if m.cursor > 0 {
m.cursor--
}
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 kontainer):
// baris item pertama = headerRows + 1 (border atas panel)
if m.view == viewContainers && msg.X < m.leftWidth() && len(m.containers) > 0 {
idx := msg.Y - (m.headerRows() + 1) + m.scrollStart()
if idx >= 0 && idx < len(m.containers) {
m.cursor = idx
}
}
}
}
return m, nil
}
// --- 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.cursor < rows {
return 0
}
return m.cursor - rows + 1
}
func stateStyle(state string) (glyph, color string) {
switch state {
case "running":
return "●", "#a6e3a1"
case "paused":
return "⏸", "#f9e2af"
case "restarting", "created":
return "◌", "#89b4fa"
case "exited", "dead":
return "○", "#f38ba8"
default:
return "○", "#6c7086"
}
}
// --- styling helpers ---
func dim(s string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#6c7086")).Render(s)
}
func text(s string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#cdd6f4")).Render(s)
}
func red(s string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#f38ba8")).Render(s)
}
func styled(color, s string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).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]) + "…"
}
// 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 ""
}
bc := "#6c7086"
if focused {
bc = "#cba6f7"
}
border := lipgloss.NewStyle().Foreground(lipgloss.Color(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 := lipgloss.NewStyle().Bold(true).
Foreground(lipgloss.Color("#cba6f7")).
Render("tia — Terminal Is A My Way")
var body, footer string
switch m.view {
case viewHome:
header += dim(" · beranda")
body = m.homeBody()
footer = m.footerLine("[1/enter] kontainer · [t] toast · [a] reduced-motion · [q] keluar")
default:
header += dim(" · kontainer")
body = lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView())
footer = m.footerLine("[j/k] pilih · [r] muat ulang · [0/esc] beranda · [q] keluar")
}
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(fmt.Sprintf("%s animasi %s fps · audio: %s", hints, anim, m.sound.Status()), m.width))
}
func (m Model) sidebarView() string {
w, h := m.leftWidth(), m.bodyHeight()
title, meta := "Kontainer", fmt.Sprintf("(%d)", len(m.containers))
if m.loading {
meta = "memuat…"
}
var lines []string
switch {
case m.loadErr != nil:
lines = append(lines, red("GAGAL memuat"), dim("tekan r untuk coba lagi"))
case m.loading:
lines = append(lines, dim("memuat…"))
case len(m.containers) == 0:
lines = append(lines, dim("belum ada kontainer"))
default:
rows := h - 2
start := m.scrollStart()
innerW := w - 4
for i := start; i < len(m.containers) && i-start < rows; i++ {
c := m.containers[i]
glyph, color := stateStyle(c.State)
mark := " "
if i == m.cursor {
mark = styled("#cba6f7", "▶ ")
}
lines = append(lines, mark+styled(color, glyph)+" "+text(truncate(c.Name, innerW-4)))
}
}
return panel(title, meta, lines, w, h, false)
}
func (m Model) contentView() string {
w, h := m.width-m.leftWidth(), m.bodyHeight()
title, meta := "Detail", ""
var lines []string
switch {
case m.loadErr != nil:
lines = append(lines, red("Gagal memuat daftar kontainer"), "", dim(truncate(m.loadErr.Error(), w-6)))
case len(m.containers) == 0:
lines = append(lines, dim("Belum ada kontainer — jalankan sesuatu dengan Docker, lalu tekan r."))
default:
c := m.containers[min(m.cursor, len(m.containers)-1)]
glyph, color := stateStyle(c.State)
title, meta = c.Name, c.State
lines = append(lines,
text("ID ")+dim(c.ID),
text("Image ")+dim(truncate(c.Image, w-20)),
text("State ")+styled(color, glyph+" "+c.State),
text("Status ")+dim(truncate(c.Status, w-20)),
"",
dim("Tab detail (Log · Statistik · Konfigurasi) menyusul di Phase 2."),
)
}
return panel(title, meta, lines, w, h, false)
}