From 5450a89a80feb42a8f674216df6d3d11f61e6278 Mon Sep 17 00:00:00 2001 From: oxpoe <324556608+oxpoe@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:16:32 +0700 Subject: [PATCH] =?UTF-8?q?layout:=20ikuti=20mock=20SVG=20=E2=80=94=20kolo?= =?UTF-8?q?m=20tengah=20dashboard,=20toolbar=20workspace,=20kolom=20Aksi?= =?UTF-8?q?=20Tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- cmd/tia-love/main.go | 37 +---- e2e/e2e_test.go | 115 +++++++++++++- internal/app/app.go | 289 +++++++++++++++++++++++++++------- internal/app/home.go | 145 +++++++++++++++-- internal/app/layout_test.go | 70 ++++++++ internal/audio/audio.go | 5 +- internal/i18n/locales/en.yaml | 25 ++- internal/i18n/locales/id.yaml | 25 ++- internal/version/version.go | 39 +++++ 9 files changed, 638 insertions(+), 112 deletions(-) create mode 100644 internal/app/layout_test.go create mode 100644 internal/version/version.go diff --git a/cmd/tia-love/main.go b/cmd/tia-love/main.go index bd67d19..fd40b22 100644 --- a/cmd/tia-love/main.go +++ b/cmd/tia-love/main.go @@ -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 -} diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 9189ab5..9a34086 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -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") diff --git a/internal/app/app.go b/internal/app/app.go index 23d1939..1cac767 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) diff --git a/internal/app/home.go b/internal/app/home.go index 02cb076..e09f21e 100644 --- a/internal/app/home.go +++ b/internal/app/home.go @@ -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 { diff --git a/internal/app/layout_test.go b/internal/app/layout_test.go new file mode 100644 index 0000000..bfb6a7d --- /dev/null +++ b/internal/app/layout_test.go @@ -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) + } +} diff --git a/internal/audio/audio.go b/internal/audio/audio.go index 4207166..4f969be 100644 --- a/internal/audio/audio.go +++ b/internal/audio/audio.go @@ -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() diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 1342de7..4d0780c 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -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" diff --git a/internal/i18n/locales/id.yaml b/internal/i18n/locales/id.yaml index 0ad2426..3b603aa 100644 --- a/internal/i18n/locales/id.yaml +++ b/internal/i18n/locales/id.yaml @@ -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" diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..41c35c4 --- /dev/null +++ b/internal/version/version.go @@ -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 +}