CI / test (push) Failing after 38s
- 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.
322 lines
9.6 KiB
Go
322 lines
9.6 KiB
Go
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))
|
||
}
|