layout: ikuti mock SVG — kolom tengah dashboard, toolbar workspace, kolom Aksi Tab
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.
This commit is contained in:
oxpoe committed 2026-10-16 03:16:32 +07:00
1 parent 8880660ce4
commit 5450a89a80
9 files changed
+638 -112

No files matched your search

+132 -13
View File
@@ -2,31 +2,48 @@ 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"
)
// homeBody — dashboard beranda (F-027). Tata letak terinspirasi mirador:
// grid panel dengan judul di border; baris atas Jam · Docker · Host,
// baris bawah Kontainer · Aktivitas.
func (m Model) homeBody() string {
W := max(m.width, 60)
// 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 := 9
row2H := bodyH - row1H
row1H, stripH = 9, 5
row2H = bodyH - row1H - stripH
if row2H < 5 {
row1H = 6
row2H = max(bodyH-row1H, 3)
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
@@ -37,16 +54,118 @@ func (m Model) homeBody() string {
m.hostPanel(w3, row1H),
)
k1 := int(float64(W) * 0.55)
k2 := W - k1
row2 := lipgloss.JoinHorizontal(lipgloss.Top,
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" + row2
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 {