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

+2 -35
View File
@@ -3,14 +3,13 @@ package main
import (
"fmt"
"os"
"runtime/debug"
"strings"
tea "charm.land/bubbletea/v2"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/app"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/cli"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/version"
)
func usageText() string {
@@ -33,7 +32,7 @@ func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "version", "--version":
fmt.Println(versionString())
fmt.Println(version.String())
return
case "doctor":
os.Exit(cli.Doctor(os.Stdout))
@@ -50,35 +49,3 @@ func main() {
os.Exit(1)
}
}
// versionString memakai build info VCS — tanpa ldflags.
func versionString() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return "tia dev"
}
ver := info.Main.Version
if ver == "" || ver == "(devel)" || strings.HasPrefix(ver, "v0.0.0-") {
ver = "dev"
}
rev, dirty := "", false
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
if len(s.Value) >= 7 {
rev = s.Value[:7]
}
case "vcs.modified":
dirty = s.Value == "true"
}
}
out := "tia " + ver
if rev != "" {
out += " (" + rev
if dirty {
out += "-dirty"
}
out += ")"
}
return out
}
+113 -2
View File
@@ -599,6 +599,116 @@ func TestMouseHomeContainer(t *testing.T) {
}
}
// TestHomeCtaWorkspace — klik kotak CTA "masuk workspace" di kolom tengah
// beranda → pindah ke workspace bagian Stack.
func TestHomeCtaWorkspace(t *testing.T) {
u := startTUI(t)
u.waitContains("beranda", 5*time.Second)
u.waitContains("masuk workspace", 5*time.Second)
if !u.clickText("masuk workspace") {
t.Fatal("kotak CTA tidak ditemukan di beranda")
}
u.waitContains("[u] jalankan", 3*time.Second) // footer Stack = bukti pindah view
u.send("q")
if code := u.waitExit(5 * time.Second); code != 0 {
t.Fatalf("exit code %d, mau 0", code)
}
}
// TestToolbarSearch — pill "Cari" di toolbar workspace: klik, ketik → kursor
// melompat ke kontainer yang cocok; Enter keluar mode cari (paritas "/").
func TestToolbarSearch(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) < 2 {
t.Skipf("butuh >= 2 kontainer (err=%v, n=%d)", err, len(items))
}
// Target: prefix nama yang tidak termuat di nama kontainer sebelumnya —
// pencarian melompat ke kecocokan pertama dari atas.
target := ""
for i, it := range items {
q := it.Name
if len(q) > 6 {
q = q[:6]
}
unique := true
for k := 0; k < i; k++ {
if strings.Contains(strings.ToLower(items[k].Name), strings.ToLower(q)) {
unique = false
break
}
}
if unique {
target = q
break
}
}
if target == "" {
t.Skip("nama kontainer tidak unik untuk uji cari")
}
u := startTUI(t)
u.waitContains("beranda", 5*time.Second)
u.send("2")
u.waitContains("[1-5]", 3*time.Second)
if !u.clickText("Cari") {
t.Fatal("pill Cari tidak ditemukan")
}
u.send(target)
u.waitContains("[ / "+target, 3*time.Second) // query masuk ke pill
u.waitHighlighted(target, 3*time.Second) // kursor melompat ke item cocok
u.send("\r") // Enter: keluar mode cari
u.waitContains("[ / Cari ]", 3*time.Second)
u.send("q") // bukti sudah keluar mode cari: q keluar, bukan mengetik
if code := u.waitExit(5 * time.Second); code != 0 {
t.Fatalf("exit code %d, mau 0", code)
}
}
// TestTabActionColumn — kolom kanan "Aksi Tab" (mock TAB ACTION) di detail
// kontainer: item tampil per tab dan bisa diklik (klik [S] → toggle timestamp).
func TestTabActionColumn(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)", err)
}
u := startTUI(t)
u.waitContains("beranda", 5*time.Second)
u.send("2")
u.waitContains("[1-5]", 3*time.Second)
u.waitContains("Aksi Tab", 3*time.Second)
u.waitContains("ikut ekor", 3*time.Second)
if !u.clickText("[S]") {
t.Fatal("aksi [S] tidak ditemukan di kolom aksi tab")
}
u.waitContains("Timestamp log", 3*time.Second) // toast = aksi benar-benar jalan
u.send("q")
if code := u.waitExit(5 * time.Second); code != 0 {
t.Fatalf("exit code %d, mau 0", code)
}
}
// TestConvertCommand — CLI `tia-love convert`: hasil di stdout, exit code jelas.
func TestConvertCommand(t *testing.T) {
out, err := exec.Command(binPath, "convert", "docker run -p 80:80 --name web nginx").CombinedOutput()
@@ -804,8 +914,9 @@ func TestClickSelectsContainer(t *testing.T) {
u.waitContains(short(items[0].Name), 5*time.Second)
u.waitHighlighted(short(items[0].Name), 3*time.Second) // seleksi awal (kursor 0)
// klik baris item ke-2: xterm y=4 (1-based) → app row 3 → indeks 1
u.send("\x1b[<0;10;4M\x1b[<0;10;4m")
// klik baris item ke-2: xterm y=5 (1-based) → app row 4 → indeks 1
// (header + toolbar + border panel di atasnya).
u.send("\x1b[<0;10;5M\x1b[<0;10;5m")
u.waitHighlighted(short(items[1].Name), 3*time.Second)
u.send("q")
+232 -57
View File
@@ -103,6 +103,9 @@ type Model struct {
focus focusKind
tab int
searching bool // mode cari daftar (pill toolbar): ketik → lompat ke item
query string // isi pencarian; kosong = tidak menyaring
tailLines int
showTimestamps bool
@@ -533,6 +536,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.modal != nil {
return m, m.modalKeyMsg(msg) // modal menangkap semua tombol
}
if m.searching {
return m, m.searchKeyMsg(msg) // mode cari menangkap semua teks
}
return m, m.handleKey(msg.String())
case tea.PasteMsg:
@@ -547,8 +553,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.toast.Active() {
m.toast = components.NewToast() // klik = tutup toast
}
// Elemen UI eksplisit dulu (F-022): hint footer, tab bar, panel beranda.
if cmd := m.mouseUI(msg.X, msg.Y); cmd != nil {
// Elemen UI eksplisit dulu (F-022): hint footer, toolbar, tab bar,
// kolom aksi tab, panel beranda.
if cmd, handled := m.mouseUI(msg.X, msg.Y); handled {
return m, cmd
}
m.sound.Play(audio.Click)
@@ -792,10 +799,18 @@ func (m Model) toastRows() int {
return 0
}
func (m Model) headerRows() int { return 1 + m.toastRows() }
// toolRows — baris toolbar workspace (1 di view daftar, 0 di beranda).
func (m Model) toolRows() int {
if m.view == viewList {
return 1
}
return 0
}
func (m Model) headerRows() int { return 1 + m.toolRows() + m.toastRows() }
func (m Model) bodyHeight() int {
h := m.height - 3 - m.toastRows() // header(1) + footer(1) + margin(1)
h := m.height - 3 - m.toastRows() - m.toolRows() // header + toolbar + footer + margin
if h < 5 {
h = 5
}
@@ -942,7 +957,11 @@ func (m Model) View() tea.View {
if t := m.toast.View(max(m.width, 20)); t != "" {
parts = append(parts, t)
}
parts = append(parts, header, body, footer)
parts = append(parts, header)
if m.view == viewList {
parts = append(parts, m.toolbarLine())
}
parts = append(parts, body, footer)
content := m.overlayModal(strings.Join(parts, "\n"))
@@ -965,30 +984,52 @@ func (m Model) footerPlain() string {
if m.animating {
anim = fmt.Sprintf("%d", m.fps)
}
return truncate(i18n.Tf("footer.status", hints, anim, m.sound.Status()), m.width)
full := i18n.Tf("footer.status", hints, anim, m.sound.Status())
if lipgloss.Width(full) > m.width {
// Hint aksi lebih penting daripada status diagnostik: buang status dulu.
full = truncate(hints, m.width)
}
return full
}
func (m Model) footerLine() string { return dim(m.footerPlain()) }
// mouseUI — hit-test elemen UI eksplisit terhadap mouse (F-022). Semua yang
// tampak seperti tombol/tab/hint HARUS sampai di sini.
func (m *Model) mouseUI(x, y int) tea.Cmd {
// handled=true berarti klik dikonsumsi (walau tanpa aksi) supaya tidak jatuh
// ke pemilihan baris generik di bawahnya.
func (m *Model) mouseUI(x, y int) (tea.Cmd, bool) {
// 1) Hint footer: klik "[k]" (atau labelnya) menjalankan aksi k.
if y == m.height-2 {
if key, ok := m.footerKeyAt(x); ok {
return m.handleKey(key)
return m.handleKey(key), true
}
return nil, false
}
// 2) Tab bar detail (view kontainers).
// 2) Toolbar workspace (baris di bawah header): pill cari + tombol stack.
if m.view == viewList && y == m.headerRows()-1 {
return m.toolbarKeyAt(x), true
}
// 3) Tab bar detail (view kontainers).
if m.view == viewList && m.section == sectionContainers && m.sectionLen() > 0 {
if idx, ok := m.tabAt(x, y); ok {
m.tab = idx
m.focus = focusContent
m.sound.Play(audio.Click)
return m.restartData()
return m.restartData(), true
}
// 3b) Kolom aksi tab (paling kanan): baris = item aksi tab aktif.
if _, wAct := m.detailWidths(); wAct > 0 && x >= m.width-wAct {
if row := y - (m.headerRows() + 1); row >= 0 {
items := m.tabActions()
if row < len(items) {
return m.handleKey(items[row][0]), true
}
}
return nil, true
}
}
// 3) Toolbar aksi / tombol konfirmasi stack (baris konten pertama detail).
// 4) Toolbar aksi / tombol konfirmasi stack (baris konten pertama detail).
if m.view == viewList && m.section == sectionStacks && m.sectionLen() > 0 && y == m.headerRows()+1 {
line := ""
switch {
@@ -999,19 +1040,177 @@ func (m *Model) mouseUI(x, y int) tea.Cmd {
}
if line != "" {
if key, ok := keyZonesAt(line, x-m.leftWidth()-2); ok {
return m.handleKey(key)
return m.handleKey(key), true
}
}
}
// 4) Panel beranda (baris kontainer & panel Docker).
// 5) Panel beranda (baris kontainer, panel Docker, CTA masuk workspace).
if m.view == viewHome {
if cmd := m.homeClick(x, y); cmd != nil {
return cmd
return cmd, true
}
}
return nil, false
}
// toolbarPlain — toolbar workspace tanpa gaya warna (sumber koordinat hit-test).
func (m Model) toolbarPlain() string {
search := "/ " + i18n.T("toolbar.search")
if m.searching {
search = "/ " + m.query + "▌"
}
left := "[ " + search + " ]"
right := "[ " + i18n.T("toolbar.addStack") + " ] [ " + i18n.T("toolbar.convert") + " ]"
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 2 {
return ansiTruncate(left, m.width)
}
return left + strings.Repeat(" ", gap) + right
}
func (m Model) toolbarLine() string {
th := theme.Current()
search := "/ " + i18n.T("toolbar.search")
if m.searching {
search = "/ " + m.query + "▌"
}
left := theme.Style(th.Accent).Render("[ " + search + " ]")
right := theme.Style(th.Accent).Render(
"[ " + i18n.T("toolbar.addStack") + " ] [ " + i18n.T("toolbar.convert") + " ]")
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 2 {
return ansiTruncate(left, m.width)
}
return left + strings.Repeat(" ", gap) + right
}
// toolbarKeyAt — klik toolbar workspace: pill Cari / tombol stack.
func (m *Model) toolbarKeyAt(x int) tea.Cmd {
label, ok := labelZoneAt(m.toolbarPlain(), x)
if !ok {
return nil
}
switch label {
case "/ " + i18n.T("toolbar.search"):
m.searching, m.query = true, ""
m.focus = focusSidebar
m.sound.Play(audio.Click)
// shortcut: "+ stack" untuk kini = modal konverter (jalur buat stack yang
// ada); ganti saat modal add-stack (editor stack) dibangun.
case i18n.T("toolbar.addStack"), i18n.T("toolbar.convert"):
return m.handleKey("c")
}
return nil
}
// searchKeyMsg — mode cari daftar: ketik = lompat ke item yang cocok.
// Esc batal (bersihkan), Enter bertahan di pilihan, Ctrl+C keluar aplikasi.
func (m *Model) searchKeyMsg(msg tea.KeyPressMsg) tea.Cmd {
switch {
case msg.Code == 'c' && msg.Mod&tea.ModCtrl != 0:
m.searching = false
m.stopLogs()
if m.eventsCancel != nil {
m.eventsCancel()
}
m.sound.Play(audio.ModalClose)
return tea.Quit
case msg.Code == tea.KeyEsc:
m.searching, m.query = false, ""
m.sound.Play(audio.ModalClose)
case msg.Code == tea.KeyEnter:
m.searching = false
m.sound.Play(audio.Click)
case msg.Code == tea.KeyBackspace:
if r := []rune(m.query); len(r) > 0 {
m.query = string(r[:len(r)-1])
}
return m.searchJumpCmd()
default:
if msg.Text != "" {
m.query += msg.Text
return m.searchJumpCmd()
}
}
return nil
}
// searchJumpCmd — kursor ke item pertama yang namanya memuat query (cari-lompat,
// bukan filter: daftar tetap utuh).
func (m *Model) searchJumpCmd() tea.Cmd {
q := strings.ToLower(strings.TrimSpace(m.query))
if q == "" || m.sectionLen() == 0 {
return nil
}
for i := 0; i < m.sectionLen(); i++ {
if strings.Contains(strings.ToLower(m.itemName(i)), q) {
if i == m.cur() {
return nil
}
m.setCur(i)
m.sound.Play(audio.Navigate)
return m.restartData()
}
}
return nil
}
// itemName — nama tampil item indeks i (dasar pencarian & pencocokan).
func (m Model) itemName(i int) string {
switch m.section {
case sectionStacks:
return m.stacks[i].Name
case sectionImages:
return imageDisplayName(m.images[i])
case sectionVolumes:
return m.volumes[i].Name
case sectionNetworks:
return m.networks[i].Name
default:
return m.containers[i].Name
}
}
// detailWidths — lebar panel detail kontainer & kolom aksi tab (0 = tak tampil
// di layar sempit, agar tab bar tetap utuh).
func (m Model) detailWidths() (wDetail, wAct int) {
w := m.width - m.leftWidth()
if m.section == sectionContainers && w >= 66 {
wAct = min(max(int(float64(w)*0.22), 15), 20)
}
return w - wAct, wAct
}
// tabActions — aksi kontekstual tab aktif (kolom kanan, mock "TAB ACTION").
func (m Model) tabActions() [][2]string {
switch m.tab {
case tabLogs:
return [][2]string{{"S", i18n.T("tabaction.timestamp")}, {"G", i18n.T("tabaction.follow")}}
case tabConfig, tabEnv:
return [][2]string{{"G", i18n.T("tabaction.bottom")}}
default:
return [][2]string{{"r", i18n.T("tabaction.reload")}}
}
}
// tabActionLines — baris kolom aksi tab; tinggi WAJIB sama alokasi panel
// (hit-test per-baris bergantung padanya).
func (m Model) tabActionLines(w, rows int) []string {
items := m.tabActions()
out := make([]string, 0, rows)
for _, it := range items {
out = append(out, theme.Style(theme.Current().Accent).Render("["+it[0]+"]")+" "+
text(truncate(it[1], w)))
}
for len(out) < rows {
out = append(out, "")
}
if len(out) > rows {
out = out[:rows]
}
return out
}
// footerKeyAt — posisi rune x berada di segmen hint mana; kembalikan tombolnya.
func (m *Model) footerKeyAt(x int) (string, bool) { return keyZonesAt(m.footerPlain(), x) }
@@ -1076,42 +1275,6 @@ func (m Model) tabAt(x, y int) (int, bool) {
return 0, false
}
// homeClick — panel beranda: baris kontainer → buka workspace pada item itu;
// klik panel Docker → buka bagian kontainer.
func (m *Model) homeClick(x, y int) tea.Cmd {
W := max(m.width, 60)
bodyH := m.bodyHeight()
row1H := 9
row2H := bodyH - row1H
if row2H < 5 {
row1H = 6
row2H = max(bodyH-row1H, 3)
}
w1 := int(float64(W) * 0.36)
w2 := int(float64(W) * 0.30)
k1 := int(float64(W) * 0.55)
top := m.headerRows() + row1H // border atas panel baris-2
first := top + 1 // konten baris pertama (daftar kontainer)
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()
}
return nil
}
func (m Model) sidebarView() string {
w, h := m.leftWidth(), m.bodyHeight()
title := m.sectionName()
@@ -1241,25 +1404,31 @@ func (m Model) contentView() string {
return panel(s.Name, "", lines, w, h, focused)
}
// Kontainer: tab detail (Log berfungsi; sisanya menyusul Phase 2).
// Kontainer: tab detail + kolom aksi tab di kanan (mock "TAB ACTION").
if m.section == sectionContainers {
c := m.containers[idx]
lines := []string{m.tabBarLine(w - 4)}
wDetail, wAct := m.detailWidths()
lines := []string{m.tabBarLine(wDetail - 4)}
switch m.tab {
case tabLogs:
lines = append(lines, m.logLines(h-3)...)
case tabStats:
lines = append(lines, m.statsLines(h-3, w-4)...)
lines = append(lines, m.statsLines(h-3, wDetail-4)...)
case tabConfig:
lines = append(lines, m.inspectLines(h-3, w-4)...)
lines = append(lines, m.inspectLines(h-3, wDetail-4)...)
case tabEnv:
lines = append(lines, m.envLines(h-3, w-4)...)
lines = append(lines, m.envLines(h-3, wDetail-4)...)
case tabTop:
lines = append(lines, m.procLines(h-3, w-4)...)
lines = append(lines, m.procLines(h-3, wDetail-4)...)
default:
lines = append(lines, "", dim(i18n.T("hint.tabComing")))
}
return panel(c.Name, c.State, lines, w, h, focused)
detail := panel(c.Name, c.State, lines, wDetail, h, focused)
if wAct == 0 {
return detail
}
return lipgloss.JoinHorizontal(lipgloss.Top, detail,
panel(i18n.T("tab.action"), "", m.tabActionLines(wAct-4, h-2), wAct, h, focused))
}
title, meta := m.sectionName(), ""
@@ -1648,6 +1817,12 @@ func (m *Model) handleKey(key string) tea.Cmd {
case "c": // modal konverter docker run → compose (PRD §5.1.3)
m.modal = newConverterModal()
m.sound.Play(audio.ModalOpen)
case "/": // mode cari daftar (paritas keyboard pill "Cari" di toolbar)
if m.view == viewList {
m.searching, m.query = true, ""
m.focus = focusSidebar
m.sound.Play(audio.Click)
}
case "u", "s", "R", "p":
if m.view == viewList && m.section == sectionStacks {
return m.beginStackAction(key)
+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 {
+70
View File
@@ -0,0 +1,70 @@
package app
import (
"strings"
"testing"
"charm.land/lipgloss/v2"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n"
)
// TestDetailWidths — kolom aksi tab tampil hanya bila layar cukup lebar
// (tab bar detail tetap utuh di layar sempit).
func TestDetailWidths(t *testing.T) {
m := Model{width: 120, section: sectionContainers}
wDetail, wAct := m.detailWidths()
if wAct == 0 {
t.Fatalf("layar 120: kolom aksi harus tampil (wDetail=%d)", wDetail)
}
if wDetail+wAct != 120-m.leftWidth() {
t.Fatalf("layar 120: lebar tidak menjumlah (wDetail=%d wAct=%d)", wDetail, wAct)
}
m.width = 80
if _, wAct = m.detailWidths(); wAct != 0 {
t.Fatalf("layar 80: kolom aksi harus disembunyikan, dapat %d", wAct)
}
}
// TestToolbarPlain — toolbar workspace memuat pill cari + tombol stack,
// menampilkan query saat mode cari, dan tidak melebihi lebar layar.
func TestToolbarPlain(t *testing.T) {
m := Model{width: 80, view: viewList}
line := m.toolbarPlain()
if !strings.Contains(line, "[ / "+i18n.T("toolbar.search")+" ]") {
t.Fatalf("pill cari tidak ada: %q", line)
}
if !strings.Contains(line, i18n.T("toolbar.addStack")) || !strings.Contains(line, i18n.T("toolbar.convert")) {
t.Fatalf("tombol stack/konversi tidak ada: %q", line)
}
if w := lipgloss.Width(line); w > 80 {
t.Fatalf("toolbar melebihi lebar layar: %d", w)
}
m.searching, m.query = true, "abc"
if !strings.Contains(m.toolbarPlain(), "/ abc") {
t.Fatalf("query tidak tampil di pill: %q", m.toolbarPlain())
}
}
// TestSearchJump — pencarian melompat ke item pertama yang cocok (daftar tetap
// utuh), dan nama item mengikuti seksi aktif.
func TestSearchJump(t *testing.T) {
m := Model{
section: sectionContainers,
containers: []docker.Container{{Name: "alfa"}, {Name: "beta"}, {Name: "betawi"}},
}
m.query = "beta"
m.searchJumpCmd()
if got := m.cur(); got != 1 {
t.Fatalf("kursor = %d, mau 1 (kecocokan pertama)", got)
}
if m.itemName(2) != "betawi" {
t.Fatalf("itemName(2) = %q", m.itemName(2))
}
m.query = "tidak-ada"
m.searchJumpCmd()
if got := m.cur(); got != 1 {
t.Fatalf("tanpa kecocokan kursor harus tetap: %d", got)
}
}
+4 -1
View File
@@ -125,6 +125,9 @@ func (e *Engine) WaitReady(d time.Duration) bool {
// Status ringkas untuk status bar / `tia-love doctor`.
func (e *Engine) Status() string {
if e == nil { // Engine zero-value aman (Model tanpa audio.New)
return "nonaktif"
}
e.mu.RLock()
defer e.mu.RUnlock()
switch {
@@ -144,7 +147,7 @@ func (e *Engine) Status() string {
// Play memutar efek; fire-and-forget, aman dipanggil dari goroutine mana pun.
// No-op bila engine belum siap / dimatikan / muted.
func (e *Engine) Play(ev Event) {
if e.muted || !e.opts.Enabled {
if e == nil || e.muted || !e.opts.Enabled {
return
}
e.mu.RLock()
+23 -2
View File
@@ -15,6 +15,17 @@ section:
networks: "Networks"
activity: "Activity"
detail: "Detail"
disclaimer: "Disclaimer"
appsInfo: "App info"
home:
enterWorkspace: "enter workspace"
disclaimer: "Destructive actions (bring a stack down, delete resources) cannot be undone — double-check."
toolbar:
search: "Search"
addStack: "+ stack"
convert: "convert"
tab:
logs: "Logs"
@@ -22,6 +33,13 @@ tab:
config: "Config"
env: "Env"
top: "Top"
action: "Tab actions"
tabaction:
timestamp: "timestamp"
follow: "follow tail"
bottom: "to bottom"
reload: "reload"
stats:
history: "CPU history (60s)"
@@ -86,6 +104,9 @@ field:
inUse: "In use"
dir: "Dir"
composeFile: "Compose"
version: "Version"
repo: "Repo"
config: "Config"
state:
loading: "loading…"
@@ -124,8 +145,8 @@ hint:
inspectWaiting: "fetching inspect…"
procsWaiting: "fetching processes…"
footerHome: "[1/enter] stacks · [t] toast · [T] theme · [c] convert · [a] reduced-motion · [q] quit"
footerStacks: "[u] start · [s] stop · [R] restart · [p] update · [D] down · [c] convert · [j/k] select · [0] home · [q] quit"
footerContainers: "[1-5] sections · [j/k] select · [Tab] focus · [h/l] tab · [c] convert · [r] reload · [0] home · [T] theme · [q] quit"
footerStacks: "[u] start · [s] stop · [R] restart · [p] update · [D] down · [j/k] select · [/] search · [0] home · [q] quit"
footerContainers: "[1-5] sections · [j/k] select · [/] search · [Tab] focus · [h/l] tab · [T] theme · [r] reload · [0] home · [q] quit"
footer:
status: "%s animation %s fps · audio: %s"
+23 -2
View File
@@ -15,6 +15,17 @@ section:
networks: "Jaringan"
activity: "Aktivitas"
detail: "Detail"
disclaimer: "Disclaimer"
appsInfo: "Info aplikasi"
home:
enterWorkspace: "masuk workspace"
disclaimer: "Aksi destruktif (turunkan stack, hapus resource) tidak bisa dibatalkan — periksa dua kali."
toolbar:
search: "Cari"
addStack: "+ stack"
convert: "konversi"
tab:
logs: "Log"
@@ -22,6 +33,13 @@ tab:
config: "Konfigurasi"
env: "Env"
top: "Proses"
action: "Aksi Tab"
tabaction:
timestamp: "timestamp"
follow: "ikut ekor"
bottom: "ke bawah"
reload: "muat ulang"
stats:
history: "Riwayat CPU (60 dtk)"
@@ -86,6 +104,9 @@ field:
inUse: "Dipakai"
dir: "Dir"
composeFile: "Compose"
version: "Versi"
repo: "Repo"
config: "Config"
state:
loading: "memuat…"
@@ -124,8 +145,8 @@ hint:
inspectWaiting: "mengambil inspect…"
procsWaiting: "mengambil daftar proses…"
footerHome: "[1/enter] stack · [t] toast · [T] tema · [c] konversi · [a] reduced-motion · [q] keluar"
footerStacks: "[u] jalankan · [s] henti · [R] ulang · [p] perbarui · [D] turunkan · [c] konversi · [j/k] pilih · [0] beranda · [q] keluar"
footerContainers: "[1-5] bagian · [j/k] pilih · [Tab] fokus · [h/l] tab · [c] konversi · [r] muat ulang · [0] beranda · [T] tema · [q] keluar"
footerStacks: "[u] jalankan · [s] henti · [R] ulang · [p] perbarui · [D] turunkan · [j/k] pilih · [/] cari · [0] beranda · [q] keluar"
footerContainers: "[1-5] bagian · [j/k] pilih · [/] cari · [Tab] fokus · [h/l] tab · [T] tema · [r] muat ulang · [0] beranda · [q] keluar"
footer:
status: "%s animasi %s fps · audio: %s"
+39
View File
@@ -0,0 +1,39 @@
// Package version — string versi & build info (dipakai CLI dan TUI).
package version
import (
"runtime/debug"
"strings"
)
// String memakai build info VCS — tanpa ldflags.
func String() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return "tia dev"
}
ver := info.Main.Version
if ver == "" || ver == "(devel)" || strings.HasPrefix(ver, "v0.0.0-") {
ver = "dev"
}
rev, dirty := "", false
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
if len(s.Value) >= 7 {
rev = s.Value[:7]
}
case "vcs.modified":
dirty = s.Value == "true"
}
}
out := "tia " + ver
if rev != "" {
out += " (" + rev
if dirty {
out += "-dirty"
}
out += ")"
}
return out
}