feat(ui): sidebar kontainer + panel detail (ListContainers, navigasi j/k, klik pilih, refresh r)
CI / test (push) Successful in 21s

This commit is contained in:
oxpoe committed 2026-10-15 09:31:39 +07:00
1 parent 18055979da
commit fe781591e3
4 files changed
+336 -42

No files matched your search

+34 -4
View File
@@ -8,6 +8,7 @@
package e2e package e2e
import ( import (
"context"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -18,6 +19,8 @@ import (
"time" "time"
"github.com/creack/pty" "github.com/creack/pty"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker"
) )
const ( const (
@@ -210,12 +213,39 @@ func TestDoctorCommand(t *testing.T) {
func TestMouseAndNavigateKeys(t *testing.T) { func TestMouseAndNavigateKeys(t *testing.T) {
u := startTUI(t) u := startTUI(t)
u.waitContains("belum ada klik", 5*time.Second) u.waitContains("Kontainer", 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.send("j") u.send("j")
u.send("k") 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") u.send("q")
if code := u.waitExit(5 * time.Second); code != 0 { if code := u.waitExit(5 * time.Second); code != 0 {
t.Fatalf("exit code %d, mau 0", code) t.Fatalf("exit code %d, mau 0", code)
+252 -38
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"context"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@@ -9,45 +10,74 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio" "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/animation"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components"
) )
// Model root sementara (T-0.2–T-0.4, Phase 0). // containersMsg — hasil muat daftar kontainer (async via tea.Cmd).
// Membuktikan Bubble Tea v2 (View struct, mouse, resize, ticker 30 FPS), type containersMsg struct {
// audio engine, dan toast animasi sebelum layout penuh Phase 1. items []docker.Container
err error
}
type Model struct { type Model struct {
width, height int width, height int
clicks int
lastClick string
sound *audio.Engine sound *audio.Engine
toast components.Toast
tickID int64 tickID int64
animating bool animating bool
phase float64 phase float64
toast components.Toast
frames int frames int
fps int fps int
lastFPSAt time.Time lastFPSAt time.Time
cl *docker.Client
containers []docker.Container
cursor int
loadErr error
loading bool
} }
func New() Model { func New() Model {
return Model{ cl, err := docker.New()
lastClick: "belum ada klik", m := Model{
sound: audio.New(audio.Options{Enabled: true, Volume: 70, MuteOnSSH: true}), sound: audio.New(audio.Options{Enabled: true, Volume: 70, MuteOnSSH: true}),
tickID: animation.NewID(), tickID: animation.NewID(),
animating: true, animating: true,
toast: components.NewToast(), 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) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case animation.TickMsg: case animation.TickMsg:
if msg.ID != m.tickID || !m.animating { 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.phase += 0.9
m.toast.Update(animation.FrameInterval) 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) 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: case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height 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": case "q", "ctrl+c", "esc":
m.sound.Play(audio.ModalClose) m.sound.Play(audio.ModalClose)
return m, tea.Quit return m, tea.Quit
case "a": // reduced motion: hentikan/nyalakan ticker case "a": // reduced motion
m.animating = !m.animating m.animating = !m.animating
if m.animating { if m.animating {
m.tickID = animation.NewID() m.tickID = animation.NewID()
return m, animation.Next(m.tickID) return m, animation.Next(m.tickID)
} }
return m, nil case "r": // muat ulang daftar kontainer
case "t": 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", m.toast.Show("✔ [ SUKSES ] Konversi Berhasil Disimpan",
"Stack 'web-app' tersimpan di ~/.tia/stacks/web-app/") "Stack 'web-app' tersimpan di ~/.tia/stacks/web-app/")
m.sound.Play(audio.NotifySuccess) m.sound.Play(audio.NotifySuccess)
case "j", "down", "k", "up": case "j", "down":
m.phase += 6 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) m.sound.Play(audio.Navigate)
} }
case tea.MouseClickMsg: case tea.MouseClickMsg:
if msg.Button == tea.MouseLeft { if msg.Button == tea.MouseLeft {
m.clicks++ m.sound.Play(audio.Click)
m.lastClick = fmt.Sprintf("klik #%d di (%d, %d)", m.clicks, msg.X, msg.Y)
if m.toast.Active() { if m.toast.Active() {
m.toast = components.NewToast() // klik = tutup toast 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 return m, nil
} }
func (m Model) View() tea.View { // --- layout helpers ---
title := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#cba6f7")).
Render("tia — Terminal Is A My Way")
fps := "—" func (m Model) toastRows() int {
if m.animating { if m.toast.Active() {
fps = fmt.Sprintf("%d", m.fps) return 5 // 4 baris toast + 1 baris jarak
} }
statusline := lipgloss.NewStyle(). return 0
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")
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()). Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#6c7086")). BorderForeground(lipgloss.Color("#6c7086")).
Padding(0, 2). Width(w-4).
Render(fmt.Sprintf("%s\nukuran jendela: %dx%d", m.lastClick, m.width, m.height)) Height(h-2).
Padding(0, 1).
Render(strings.Join(lines, "\n"))
}
parts := []string{title, "", animation.RenderLogo(m.phase), "", statusline, hint, "", box} func dim(s string) string {
if t := m.toast.View(max(m.width, 20)); t != "" { return lipgloss.NewStyle().Foreground(lipgloss.Color("#6c7086")).Render(s)
parts = append([]string{t, ""}, parts...) }
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 := tea.NewView(strings.Join(parts, "\n"))
v.AltScreen = true v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion v.MouseMode = tea.MouseModeCellMotion
return v 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)
}
+38
View File
@@ -5,7 +5,9 @@ package docker
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client" "github.com/docker/docker/client"
) )
@@ -60,3 +62,39 @@ func (c *Client) Ping(ctx context.Context) error {
} }
return nil 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
}
+12
View File
@@ -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", 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.ServerVersion, info.APIVersion, info.Running, info.Containers,
info.Images, info.CPUs, info.MemoryTotal/1024/1024) 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)
}
} }