diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 16644df..c9688f4 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -8,6 +8,7 @@ package e2e import ( + "context" "os" "os/exec" "path/filepath" @@ -18,6 +19,8 @@ import ( "time" "github.com/creack/pty" + + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker" ) const ( @@ -210,12 +213,39 @@ func TestDoctorCommand(t *testing.T) { func TestMouseAndNavigateKeys(t *testing.T) { u := startTUI(t) - u.waitContains("belum ada klik", 5*time.Second) - // klik kiri di tengah layar (SGR 1006: ESC [ < 0 ; x ; y M) - u.send("\x1b[<0;60;10M\x1b[<0;60;10m") - u.waitContains("klik #1", 3*time.Second) + u.waitContains("Kontainer", 5*time.Second) u.send("j") u.send("k") + u.send("\x1b[<0;10;3M\x1b[<0;10;3m") // klik kiri di area sidebar + u.send("r") // muat ulang + u.send("q") + if code := u.waitExit(5 * time.Second); code != 0 { + t.Fatalf("exit code %d, mau 0", code) + } +} + +// TestClickSelectsContainer — lokal/CI-with-docker saja: klik baris sidebar +// harus memindahkan penanda pilihan (▶) ke kontainer. +func TestClickSelectsContainer(t *testing.T) { + if _, err := os.Stat("/var/run/docker.sock"); err != nil { + t.Skip("docker socket tidak ada") + } + cl, err := docker.New() + if err != nil { + t.Skipf("docker: %v", err) + } + defer cl.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + items, err := cl.ListContainers(ctx) + if err != nil || len(items) == 0 { + t.Skipf("tidak ada kontainer (err=%v, n=%d)", err, len(items)) + } + + u := startTUI(t) + u.waitContains("Kontainer (", 5*time.Second) + u.send("\x1b[<0;10;4M\x1b[<0;10;4m") // klik baris item pertama sidebar + u.waitContains("▶", 3*time.Second) u.send("q") if code := u.waitExit(5 * time.Second); code != 0 { t.Fatalf("exit code %d, mau 0", code) diff --git a/internal/app/app.go b/internal/app/app.go index 1bc88f9..3cfd2bb 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,6 +1,7 @@ package app import ( + "context" "fmt" "strings" "time" @@ -9,45 +10,74 @@ import ( "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" ) -// Model root sementara (T-0.2–T-0.4, Phase 0). -// Membuktikan Bubble Tea v2 (View struct, mouse, resize, ticker 30 FPS), -// audio engine, dan toast animasi sebelum layout penuh Phase 1. +// containersMsg — hasil muat daftar kontainer (async via tea.Cmd). +type containersMsg struct { + items []docker.Container + err error +} + type Model struct { width, height int - clicks int - lastClick string sound *audio.Engine + toast components.Toast tickID int64 animating bool phase float64 - toast components.Toast frames int fps int lastFPSAt time.Time + + cl *docker.Client + containers []docker.Container + cursor int + loadErr error + loading bool } func New() Model { - return Model{ - lastClick: "belum ada klik", + 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 { return animation.Next(m.tickID) } +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 / animasi dimatikan oleh reduced-motion + return m, nil // tick basi / reduced-motion } m.phase += 0.9 m.toast.Update(animation.FrameInterval) @@ -62,6 +92,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } 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 @@ -70,65 +115,234 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "q", "ctrl+c", "esc": m.sound.Play(audio.ModalClose) return m, tea.Quit - case "a": // reduced motion: hentikan/nyalakan ticker + case "a": // reduced motion m.animating = !m.animating if m.animating { m.tickID = animation.NewID() return m, animation.Next(m.tickID) } - return m, nil - case "t": + 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", "k", "up": - m.phase += 6 + 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.clicks++ - m.lastClick = fmt.Sprintf("klik #%d di (%d, %d)", m.clicks, msg.X, msg.Y) + m.sound.Play(audio.Click) if m.toast.Active() { m.toast = components.NewToast() // klik = tutup toast } - m.sound.Play(audio.Click) + // 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 } -func (m Model) View() tea.View { - title := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#cba6f7")). - Render("tia — Terminal Is A My Way") +// --- layout helpers --- - fps := "—" - if m.animating { - fps = fmt.Sprintf("%d", m.fps) +func (m Model) toastRows() int { + if m.toast.Active() { + return 5 // 4 baris toast + 1 baris jarak } - statusline := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#a6adc8")). - Render(fmt.Sprintf("Just Work · Phase 0 · animasi %s fps · audio: %s", fps, m.sound.Status())) - hint := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6c7086")). - Render("[t] toast · [a] reduced-motion · klik / j / k = bunyi · q = keluar") + return 0 +} - box := lipgloss.NewStyle(). +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")). - Padding(0, 2). - Render(fmt.Sprintf("%s\nukuran jendela: %dx%d", m.lastClick, m.width, m.height)) + Width(w-4). + Height(h-2). + Padding(0, 1). + Render(strings.Join(lines, "\n")) +} - parts := []string{title, "", animation.RenderLogo(m.phase), "", statusline, hint, "", box} - if t := m.toast.View(max(m.width, 20)); t != "" { - parts = append([]string{t, ""}, parts...) +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) +} diff --git a/internal/docker/client.go b/internal/docker/client.go index afc8c02..7130dfa 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -5,7 +5,9 @@ package docker import ( "context" "fmt" + "strings" + "github.com/docker/docker/api/types/container" "github.com/docker/docker/client" ) @@ -60,3 +62,39 @@ func (c *Client) Ping(ctx context.Context) error { } return nil } + +// Container — ringkasan kontainer untuk sidebar/dashboard. +type Container struct { + ID string + Name string + Image string + State string // running, exited, paused, restarting, created, dead + Status string // "Up 2 hours (healthy)" +} + +// ListContainers mengembalikan semua kontainer (termasuk yang berhenti). +func (c *Client) ListContainers(ctx context.Context) ([]Container, error) { + list, err := c.cli.ContainerList(ctx, container.ListOptions{All: true}) + if err != nil { + return nil, fmt.Errorf("docker: daftar kontainer: %w", err) + } + out := make([]Container, 0, len(list)) + for _, ct := range list { + name := "" + if len(ct.Names) > 0 { + name = strings.TrimPrefix(ct.Names[0], "/") + } + id := ct.ID + if len(id) > 12 { + id = id[:12] + } + out = append(out, Container{ + ID: id, + Name: name, + Image: ct.Image, + State: ct.State, + Status: ct.Status, + }) + } + return out, nil +} diff --git a/internal/docker/client_test.go b/internal/docker/client_test.go index 79365b9..71bf2e2 100644 --- a/internal/docker/client_test.go +++ b/internal/docker/client_test.go @@ -35,4 +35,16 @@ func TestIntegrationInfo(t *testing.T) { t.Logf("engine %s (API %s) · %d/%d kontainer berjalan · %d image · %d CPU · %d MB RAM", info.ServerVersion, info.APIVersion, info.Running, info.Containers, info.Images, info.CPUs, info.MemoryTotal/1024/1024) + + items, err := c.ListContainers(ctx) + if err != nil { + t.Fatalf("ListContainers: %v", err) + } + t.Logf("ListContainers: %d kontainer", len(items)) + for i, ct := range items { + if i >= 5 { + break + } + t.Logf(" - %-20s %-10s %s", ct.Name, ct.State, ct.Image) + } }