349 lines
8.5 KiB
Go
349 lines
8.5 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"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/ui/animation"
|
|
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components"
|
|
)
|
|
|
|
// containersMsg — hasil muat daftar kontainer (async via tea.Cmd).
|
|
type containersMsg struct {
|
|
items []docker.Container
|
|
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
|
|
|
|
cl *docker.Client
|
|
containers []docker.Container
|
|
cursor int
|
|
loadErr error
|
|
loading bool
|
|
}
|
|
|
|
func New() Model {
|
|
cl, err := docker.New()
|
|
m := Model{
|
|
sound: audio.New(audio.Options{Enabled: true, Volume: 70, MuteOnSSH: true}),
|
|
tickID: animation.NewID(),
|
|
animating: true,
|
|
toast: components.NewToast(),
|
|
cl: cl,
|
|
loading: true,
|
|
}
|
|
if err != nil {
|
|
m.loadErr = 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))
|
|
}
|
|
|
|
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 (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
|
|
}
|
|
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, nil
|
|
|
|
case tea.WindowSizeMsg:
|
|
m.width, m.height = msg.Width, msg.Height
|
|
|
|
case tea.KeyPressMsg:
|
|
switch msg.String() {
|
|
case "q", "ctrl+c", "esc":
|
|
m.sound.Play(audio.ModalClose)
|
|
return m, tea.Quit
|
|
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 daftar kontainer
|
|
if m.cl == nil {
|
|
break
|
|
}
|
|
m.loading = true
|
|
m.sound.Play(audio.Click)
|
|
return m, loadContainers(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 sisi sidebar: baris item pertama = bodyTop + 2
|
|
// (border atas + baris judul panel)
|
|
if msg.X < m.leftWidth() && len(m.containers) > 0 {
|
|
idx := msg.Y - (m.headerRows() + 2) + 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() - 3 // tinggi dalam panel - border - baris judul
|
|
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"
|
|
}
|
|
}
|
|
|
|
func truncate(s string, w int) string {
|
|
r := []rune(s)
|
|
if w <= 1 || len(r) <= w {
|
|
return s
|
|
}
|
|
return string(r[:w-1]) + "…"
|
|
}
|
|
|
|
// box — panel ber-border; w/h = ukuran total panel (termasuk border+padding).
|
|
func box(lines []string, w, h int) string {
|
|
return lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(lipgloss.Color("#6c7086")).
|
|
Width(w-4).
|
|
Height(h-2).
|
|
Padding(0, 1).
|
|
Render(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// --- views ---
|
|
|
|
func (m Model) View() tea.View {
|
|
header := lipgloss.NewStyle().Bold(true).
|
|
Foreground(lipgloss.Color("#cba6f7")).
|
|
Render("tia — Terminal Is A My Way") +
|
|
dim(" · kontainer")
|
|
|
|
body := lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView())
|
|
|
|
anim := "—"
|
|
if m.animating {
|
|
anim = fmt.Sprintf("%d", m.fps)
|
|
}
|
|
footerPlain := fmt.Sprintf(
|
|
"[j/k] pilih · [r] muat ulang · [t] toast · [a] reduced-motion · [q] keluar animasi %s fps · audio: %s",
|
|
anim, m.sound.Status())
|
|
footer := dim(truncate(footerPlain, m.width))
|
|
|
|
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) sidebarView() string {
|
|
w, h := m.leftWidth(), m.bodyHeight()
|
|
innerW := w - 4
|
|
|
|
head := fmt.Sprintf("Kontainer (%d)", len(m.containers))
|
|
if m.loading {
|
|
head = "Kontainer · memuat…"
|
|
}
|
|
lines := []string{lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#cdd6f4")).Render(head)}
|
|
|
|
switch {
|
|
case m.loadErr != nil:
|
|
lines = append(lines,
|
|
lipgloss.NewStyle().Foreground(lipgloss.Color("#f38ba8")).Render("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 - 3
|
|
start := m.scrollStart()
|
|
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 = lipgloss.NewStyle().Foreground(lipgloss.Color("#cba6f7")).Render("▶ ")
|
|
}
|
|
line := mark +
|
|
lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(glyph) + " " +
|
|
lipgloss.NewStyle().Foreground(lipgloss.Color("#cdd6f4")).Render(truncate(c.Name, innerW-4))
|
|
lines = append(lines, line)
|
|
}
|
|
}
|
|
return box(lines, w, h)
|
|
}
|
|
|
|
func (m Model) contentView() string {
|
|
w, h := m.width-m.leftWidth(), m.bodyHeight()
|
|
|
|
var lines []string
|
|
switch {
|
|
case m.loadErr != nil:
|
|
lines = append(lines,
|
|
lipgloss.NewStyle().Foreground(lipgloss.Color("#f38ba8")).Render("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)
|
|
lines = append(lines,
|
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#cdd6f4")).Render(c.Name),
|
|
"",
|
|
text("ID ")+dim(c.ID),
|
|
text("Image ")+dim(truncate(c.Image, w-20)),
|
|
text("State ")+lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(glyph+" "+c.State),
|
|
text("Status ")+dim(truncate(c.Status, w-20)),
|
|
"",
|
|
dim("Tab detail (Log · Statistik · Konfigurasi) menyusul di Phase 2."),
|
|
)
|
|
}
|
|
return box(lines, w, h)
|
|
}
|