Files
tia-love/internal/app/home.go
T
oxpoe 5450a89a80
CI / test (push) Failing after 38s
layout: ikuti mock SVG — kolom tengah dashboard, toolbar workspace, kolom Aksi Tab
- Beranda: kolom tengah CTA 'masuk workspace' (klikable), strip Disclaimer
  dan Info aplikasi (versi/repo/config); geometri satu sumber (homeGeom).
- Workspace: baris toolbar — pill Cari (klik atau '/' → cari-lompat daftar)
  + tombol [+ stack]/[konversi] klikable (sementara keduanya jalur konverter).
- Detail kontainer: kolom kanan 'Aksi Tab' (mock TAB ACTION) berisi aksi tab
  aktif yang bisa diklik; disembunyikan di layar sempit agar tab bar utuh.
- Footer: hint aksi diprioritaskan; status diagnostik dibuang bila tak muat.
- internal/version: versi build dipakai CLI & strip Info aplikasi.
- audio: Play/Status aman untuk Engine nil (Model zero-value di tes).
- Tes: 3 skenario E2E baru (CTA, cari toolbar, kolom aksi) + tes layout.
2026-10-16 03:16:32 +07:00

322 lines
9.6 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 (
"fmt"
"os"
"runtime"
"strings"
"time"
"charm.land/lipgloss/v2"
tea "charm.land/bubbletea/v2"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/config"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/theme"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/version"
)
// homeGeom — geometri dashboard (satu sumber untuk render & hit-test klik).
// Baris: 3 kartu atas · area bawah (Kontainer | CTA | Aktivitas) · strip bawah.
func (m Model) homeGeom() (W, row1H, stripH, row2H, cw, k1, k2 int) {
W = max(m.width, 60)
bodyH := m.bodyHeight()
row1H, stripH = 9, 5
row2H = bodyH - row1H - stripH
if row2H < 5 {
row1H, stripH = 6, 4
row2H = max(bodyH-row1H-stripH, 3)
}
cw = min(max(int(float64(W)*0.20), 18), 26) // kolom tengah: CTA masuk workspace
k1 = (W - cw) / 2
k2 = W - cw - k1
return
}
// homeBody — dashboard beranda (F-027). Tata letak terinspirasi mirador dengan
// kolom tengah CTA ala mock pengguna: kartu Jam · Engine · Host di atas,
// Kontainer | CTA | Aktivitas di tengah, strip Disclaimer & Info aplikasi di bawah.
func (m Model) homeBody() string {
W, row1H, stripH, row2H, cw, k1, k2 := m.homeGeom()
w1 := int(float64(W) * 0.36)
w2 := int(float64(W) * 0.30)
w3 := W - w1 - w2
row1 := lipgloss.JoinHorizontal(lipgloss.Top,
m.clockPanel(w1, row1H),
m.dockerPanel(w2, row1H),
m.hostPanel(w3, row1H),
)
left := lipgloss.JoinVertical(lipgloss.Left,
panel(i18n.T("section.containers"), i18n.Tf("fmt.countTotal", len(m.containers)),
m.miniContainerLines(row2H-2), k1, row2H, false),
m.disclaimerPanel(k1, stripH),
)
mid, _, _ := m.centerColumn(cw, row2H+stripH)
right := lipgloss.JoinVertical(lipgloss.Left,
panel(i18n.T("section.activity"), fmt.Sprintf("(%d)", len(m.activity)),
m.activityLines(k2-4, row2H-2), k2, row2H, false),
m.appsPanel(k2, stripH),
)
return row1 + "\n" + lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Join(mid, "\n"), right)
}
// centerColumn — kolom tengah: kotak CTA "masuk workspace" (klikable; paritas
// dengan tombol yang tampil). Kembalikan juga posisi kotak untuk hit-test.
func (m Model) centerColumn(w, h int) (lines []string, ctaTop, ctaH int) {
th := theme.Current()
border := theme.Style(th.Accent)
label := theme.Style(th.Accent).Bold(true)
inner := max(w-4, 8)
rows := wrapText(i18n.T("home.enterWorkspace"), inner)
ctaH = len(rows) + 2
box := []string{border.Render("╭" + strings.Repeat("─", w-2) + "╮")}
for _, r := range rows {
pad := inner - lipgloss.Width(r)
l := max(pad/2, 0)
box = append(box, border.Render("│")+" "+strings.Repeat(" ", l)+
label.Render(r)+strings.Repeat(" ", max(pad-l, 0))+" "+border.Render("│"))
}
box = append(box, border.Render("╰"+strings.Repeat("─", w-2)+"╯"))
ctaTop = max((h-ctaH)/2, 0)
lines = make([]string, 0, h)
for i := 0; i < h; i++ {
idx := i - ctaTop
switch {
case idx >= 0 && idx < ctaH:
lines = append(lines, box[idx])
default:
lines = append(lines, strings.Repeat(" ", w))
}
}
return
}
// disclaimerPanel — strip peringatan aksi destruktif.
func (m Model) disclaimerPanel(w, h int) string {
rows := wrapText(i18n.T("home.disclaimer"), max(w-4, 8))
if len(rows) > h-2 {
rows = rows[:h-2]
}
return panel(i18n.T("section.disclaimer"), "", rows, w, h, false)
}
const repoURL = "gitea.oxpoe.qzz.io/oxpoe/tia-love"
// appsPanel — strip info aplikasi: versi, repo, lokasi config.
func (m Model) appsPanel(w, h int) string {
rows := []string{
fieldW(i18n.T("field.version"), 7) + dim(truncate(version.String(), w-9)),
fieldW(i18n.T("field.repo"), 7) + dim(truncate(repoURL, w-9)),
fieldW(i18n.T("field.config"), 7) + dim(truncate(prettyPath(config.Path()), w-9)),
}
if len(rows) > h-2 {
rows = rows[:h-2]
}
return panel(i18n.T("section.appsInfo"), "", rows, w, h, false)
}
// prettyPath — tampilkan path config dengan "~" untuk home pengguna.
func prettyPath(p string) string {
home, err := os.UserHomeDir()
if err == nil && home != "" && strings.HasPrefix(p, home+"/") {
return "~" + strings.TrimPrefix(p, home)
}
return p
}
// homeClick — panel beranda: baris kontainer → buka workspace pada item itu;
// panel Engine → bagian kontainer; kotak CTA → workspace (Stacks).
func (m *Model) homeClick(x, y int) tea.Cmd {
W, row1H, stripH, row2H, cw, k1, _ := m.homeGeom()
w1 := int(float64(W) * 0.36)
w2 := int(float64(W) * 0.30)
top := m.headerRows() + row1H // baris pertama area bawah
first := top + 1 // konten baris pertama (daftar kontainer)
_, ctaTop, ctaH := m.centerColumn(cw, row2H+stripH)
switch {
case y >= first && y < first+max(row2H-2, 0) && x < k1:
idx := y - first
if len(m.containers) == 0 || idx >= len(m.containers) {
return nil
}
m.jumpSection(sectionContainers)
m.setCur(idx)
m.sound.Play(audio.Click)
return m.restartData()
case y >= m.headerRows()+1 && y < m.headerRows()+row1H && x >= w1 && x < w1+w2:
m.jumpSection(sectionContainers)
m.sound.Play(audio.Click)
return m.restartData()
case y >= top+ctaTop && y < top+ctaTop+ctaH && x >= k1 && x < k1+cw:
m.jumpSection(sectionStacks)
m.sound.Play(audio.Click)
return m.restartData()
}
return nil
}
func (m Model) clockPanel(w, h int) string {
now := time.Now()
rows := components.CenterBlock(components.RenderClock(now), w-4)
for i, r := range rows {
rows[i] = theme.Style(theme.Current().Accent).Bold(true).Render(r)
}
tanggal := fmt.Sprintf("%d %s %d", now.Day(), i18n.MonthName(now), now.Year())
lines := append(rows, "", dim(" "+i18n.WeekdayName(now)+", "+tanggal+" · "+now.Format("MST")))
if len(lines) > h-2 {
lines = lines[:h-2]
}
return panel(i18n.T("section.clock"), "", lines, w, h, false)
}
// engineTitle — judul panel runtime (Docker/Podman) sesuai hasil deteksi.
func (m Model) engineTitle() string {
if m.engineName == "podman" {
return "Podman"
}
return "Docker"
}
func (m Model) dockerPanel(w, h int) string {
var lines []string
switch {
case m.engineErr != nil:
lines = append(lines, red(i18n.T("error.fail")), "", dim(truncate(m.engineErr.Error(), w-6)))
case m.engine.ServerVersion == "":
lines = append(lines, dim(i18n.T("state.loading")))
default:
lines = append(lines,
fieldW(i18n.T("field.engine"), 9)+dim(m.engine.ServerVersion),
fieldW(i18n.T("field.api"), 9)+dim(m.engine.APIVersion),
fieldW(i18n.T("section.containers"), 9)+dim(i18n.Tf("fmt.dockerContainers", m.engine.Running, m.engine.Containers)),
fieldW(i18n.T("field.image"), 9)+dim(fmt.Sprintf("%d", m.engine.Images)),
"",
styled(theme.Current().Success, i18n.T("state.connected")),
)
}
return panel(m.engineTitle(), i18n.T("host.local"), lines, w, h, false)
}
func (m Model) hostPanel(w, h int) string {
s := m.sys
cpus := m.engine.CPUs
if cpus == 0 {
cpus = runtime.NumCPU()
}
pct := 0
if s.MemTotal > 0 {
pct = int((s.MemTotal - s.MemAvail) * 100 / s.MemTotal)
}
lines := []string{
fieldW(i18n.T("field.host"), 9) + dim(m.hostname),
fieldW(i18n.T("field.cpu"), 9) + dim(i18n.Tf("fmt.coreLoad", cpus, s.Load1)),
fieldW(i18n.T("field.ram"), 9) + dim(i18n.Tf("fmt.ramUsage", pct)) + pctBar(float64(pct), 10) +
dim(fmt.Sprintf(" %s/%s", humanKBs(s.MemTotal-s.MemAvail), humanKBs(s.MemTotal))),
fieldW(i18n.T("field.uptime"), 9) + dim(humanDuration(s.Uptime)),
}
return panel(i18n.T("section.host"), "", lines, w, h, false)
}
func (m Model) miniContainerLines(maxLines int) []string {
if m.loading && len(m.containers) == 0 {
return []string{dim(i18n.T("state.loading"))}
}
if len(m.containers) == 0 {
return []string{dim(i18n.T("hint.miniNoContainers"))}
}
max := maxLines
if len(m.containers) > max {
max--
}
lines := make([]string, 0, maxLines)
for i := 0; i < len(m.containers) && i < max; i++ {
c := m.containers[i]
glyph, color := stateStyle(c.State)
lines = append(lines, styled(color, glyph)+" "+text(truncate(c.Name, 24))+" "+dim(truncate(c.Image, 32)))
}
if extra := len(m.containers) - len(lines); extra > 0 {
lines = append(lines, dim(i18n.Tf("fmt.listMore", extra)))
}
return lines
}
// --- small formatting helpers ---
func humanKB(kb uint64) string {
f := float64(kb) * 1024
units := []string{"B", "KB", "MB", "GB", "TB"}
i := 0
for f >= 1024 && i < len(units)-1 {
f /= 1024
i++
}
if i <= 1 {
return fmt.Sprintf("%.0f %s", f, units[i])
}
return fmt.Sprintf("%.1f %s", f, units[i])
}
func humanKBs(kb uint64) string {
return strings.Replace(humanKB(kb), " ", "", 1)
}
// humanBytes — ukuran byte → "1.4 GB" (untuk image).
func humanBytes(n int64) string {
f := float64(n)
units := []string{"B", "KB", "MB", "GB", "TB"}
i := 0
for f >= 1024 && i < len(units)-1 {
f /= 1024
i++
}
if i <= 1 {
return fmt.Sprintf("%.0f %s", f, units[i])
}
return fmt.Sprintf("%.1f %s", f, units[i])
}
func humanDuration(sec float64) string {
d := time.Duration(sec) * time.Second
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
mins := int(d.Minutes()) % 60
switch {
case days > 0:
return i18n.Tf("duration.days", days, hours)
case hours > 0:
return i18n.Tf("duration.hours", hours, mins)
default:
return i18n.Tf("duration.minutes", mins)
}
}
// pctBar — bar persentase; warna ikut beban (hijau <60% · kuning 60–85% · merah >85%).
func pctBar(pct float64, w int) string {
fill := int(pct * float64(w) / 100)
if fill > w {
fill = w
}
th := theme.Current()
color := th.Success
switch {
case pct >= 85:
color = th.Danger
case pct >= 60:
color = th.Warning
}
return styled(color, strings.Repeat("█", fill)) + dim(strings.Repeat("░", w-fill))
}