feat(ui): sidebar kontainer + panel detail (ListContainers, navigasi j/k, klik pilih, refresh r)
CI / test (push) Successful in 21s
CI / test (push) Successful in 21s
This commit is contained in:
1 parent
18055979da
commit
fe781591e3
4 files changed
+336
-42
No files matched your search
+34
-4
@@ -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)
|
||||
|
||||
+252
-38
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user