layout: ikuti mock SVG — kolom tengah dashboard, toolbar workspace, kolom Aksi Tab
CI / test (push) Failing after 38s
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:
1 parent
8880660ce4
commit
5450a89a80
9 files changed
+638
-112
No files matched your search
+2
-35
@@ -3,14 +3,13 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"runtime/debug"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/app"
|
"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/cli"
|
||||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n"
|
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n"
|
||||||
|
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
func usageText() string {
|
func usageText() string {
|
||||||
@@ -33,7 +32,7 @@ func main() {
|
|||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
switch os.Args[1] {
|
switch os.Args[1] {
|
||||||
case "version", "--version":
|
case "version", "--version":
|
||||||
fmt.Println(versionString())
|
fmt.Println(version.String())
|
||||||
return
|
return
|
||||||
case "doctor":
|
case "doctor":
|
||||||
os.Exit(cli.Doctor(os.Stdout))
|
os.Exit(cli.Doctor(os.Stdout))
|
||||||
@@ -50,35 +49,3 @@ func main() {
|
|||||||
os.Exit(1)
|
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
@@ -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.
|
// TestConvertCommand — CLI `tia-love convert`: hasil di stdout, exit code jelas.
|
||||||
func TestConvertCommand(t *testing.T) {
|
func TestConvertCommand(t *testing.T) {
|
||||||
out, err := exec.Command(binPath, "convert", "docker run -p 80:80 --name web nginx").CombinedOutput()
|
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.waitContains(short(items[0].Name), 5*time.Second)
|
||||||
u.waitHighlighted(short(items[0].Name), 3*time.Second) // seleksi awal (kursor 0)
|
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
|
// klik baris item ke-2: xterm y=5 (1-based) → app row 4 → indeks 1
|
||||||
u.send("\x1b[<0;10;4M\x1b[<0;10;4m")
|
// (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.waitHighlighted(short(items[1].Name), 3*time.Second)
|
||||||
|
|
||||||
u.send("q")
|
u.send("q")
|
||||||
|
|||||||
+232
-57
@@ -103,6 +103,9 @@ type Model struct {
|
|||||||
focus focusKind
|
focus focusKind
|
||||||
tab int
|
tab int
|
||||||
|
|
||||||
|
searching bool // mode cari daftar (pill toolbar): ketik → lompat ke item
|
||||||
|
query string // isi pencarian; kosong = tidak menyaring
|
||||||
|
|
||||||
tailLines int
|
tailLines int
|
||||||
showTimestamps bool
|
showTimestamps bool
|
||||||
|
|
||||||
@@ -533,6 +536,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.modal != nil {
|
if m.modal != nil {
|
||||||
return m, m.modalKeyMsg(msg) // modal menangkap semua tombol
|
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())
|
return m, m.handleKey(msg.String())
|
||||||
|
|
||||||
case tea.PasteMsg:
|
case tea.PasteMsg:
|
||||||
@@ -547,8 +553,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.toast.Active() {
|
if m.toast.Active() {
|
||||||
m.toast = components.NewToast() // klik = tutup toast
|
m.toast = components.NewToast() // klik = tutup toast
|
||||||
}
|
}
|
||||||
// Elemen UI eksplisit dulu (F-022): hint footer, tab bar, panel beranda.
|
// Elemen UI eksplisit dulu (F-022): hint footer, toolbar, tab bar,
|
||||||
if cmd := m.mouseUI(msg.X, msg.Y); cmd != nil {
|
// kolom aksi tab, panel beranda.
|
||||||
|
if cmd, handled := m.mouseUI(msg.X, msg.Y); handled {
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
m.sound.Play(audio.Click)
|
m.sound.Play(audio.Click)
|
||||||
@@ -792,10 +799,18 @@ func (m Model) toastRows() int {
|
|||||||
return 0
|
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 {
|
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 {
|
if h < 5 {
|
||||||
h = 5
|
h = 5
|
||||||
}
|
}
|
||||||
@@ -942,7 +957,11 @@ func (m Model) View() tea.View {
|
|||||||
if t := m.toast.View(max(m.width, 20)); t != "" {
|
if t := m.toast.View(max(m.width, 20)); t != "" {
|
||||||
parts = append(parts, 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"))
|
content := m.overlayModal(strings.Join(parts, "\n"))
|
||||||
|
|
||||||
@@ -965,30 +984,52 @@ func (m Model) footerPlain() string {
|
|||||||
if m.animating {
|
if m.animating {
|
||||||
anim = fmt.Sprintf("%d", m.fps)
|
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()) }
|
func (m Model) footerLine() string { return dim(m.footerPlain()) }
|
||||||
|
|
||||||
// mouseUI — hit-test elemen UI eksplisit terhadap mouse (F-022). Semua yang
|
// mouseUI — hit-test elemen UI eksplisit terhadap mouse (F-022). Semua yang
|
||||||
// tampak seperti tombol/tab/hint HARUS sampai di sini.
|
// 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.
|
// 1) Hint footer: klik "[k]" (atau labelnya) menjalankan aksi k.
|
||||||
if y == m.height-2 {
|
if y == m.height-2 {
|
||||||
if key, ok := m.footerKeyAt(x); ok {
|
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 m.view == viewList && m.section == sectionContainers && m.sectionLen() > 0 {
|
||||||
if idx, ok := m.tabAt(x, y); ok {
|
if idx, ok := m.tabAt(x, y); ok {
|
||||||
m.tab = idx
|
m.tab = idx
|
||||||
m.focus = focusContent
|
m.focus = focusContent
|
||||||
m.sound.Play(audio.Click)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 3) Toolbar aksi / tombol konfirmasi stack (baris konten pertama detail).
|
return nil, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 4) Toolbar aksi / tombol konfirmasi stack (baris konten pertama detail).
|
||||||
if m.view == viewList && m.section == sectionStacks && m.sectionLen() > 0 && y == m.headerRows()+1 {
|
if m.view == viewList && m.section == sectionStacks && m.sectionLen() > 0 && y == m.headerRows()+1 {
|
||||||
line := ""
|
line := ""
|
||||||
switch {
|
switch {
|
||||||
@@ -999,19 +1040,177 @@ func (m *Model) mouseUI(x, y int) tea.Cmd {
|
|||||||
}
|
}
|
||||||
if line != "" {
|
if line != "" {
|
||||||
if key, ok := keyZonesAt(line, x-m.leftWidth()-2); ok {
|
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 m.view == viewHome {
|
||||||
if cmd := m.homeClick(x, y); cmd != nil {
|
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
|
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.
|
// footerKeyAt — posisi rune x berada di segmen hint mana; kembalikan tombolnya.
|
||||||
func (m *Model) footerKeyAt(x int) (string, bool) { return keyZonesAt(m.footerPlain(), x) }
|
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
|
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 {
|
func (m Model) sidebarView() string {
|
||||||
w, h := m.leftWidth(), m.bodyHeight()
|
w, h := m.leftWidth(), m.bodyHeight()
|
||||||
title := m.sectionName()
|
title := m.sectionName()
|
||||||
@@ -1241,25 +1404,31 @@ func (m Model) contentView() string {
|
|||||||
return panel(s.Name, "", lines, w, h, focused)
|
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 {
|
if m.section == sectionContainers {
|
||||||
c := m.containers[idx]
|
c := m.containers[idx]
|
||||||
lines := []string{m.tabBarLine(w - 4)}
|
wDetail, wAct := m.detailWidths()
|
||||||
|
lines := []string{m.tabBarLine(wDetail - 4)}
|
||||||
switch m.tab {
|
switch m.tab {
|
||||||
case tabLogs:
|
case tabLogs:
|
||||||
lines = append(lines, m.logLines(h-3)...)
|
lines = append(lines, m.logLines(h-3)...)
|
||||||
case tabStats:
|
case tabStats:
|
||||||
lines = append(lines, m.statsLines(h-3, w-4)...)
|
lines = append(lines, m.statsLines(h-3, wDetail-4)...)
|
||||||
case tabConfig:
|
case tabConfig:
|
||||||
lines = append(lines, m.inspectLines(h-3, w-4)...)
|
lines = append(lines, m.inspectLines(h-3, wDetail-4)...)
|
||||||
case tabEnv:
|
case tabEnv:
|
||||||
lines = append(lines, m.envLines(h-3, w-4)...)
|
lines = append(lines, m.envLines(h-3, wDetail-4)...)
|
||||||
case tabTop:
|
case tabTop:
|
||||||
lines = append(lines, m.procLines(h-3, w-4)...)
|
lines = append(lines, m.procLines(h-3, wDetail-4)...)
|
||||||
default:
|
default:
|
||||||
lines = append(lines, "", dim(i18n.T("hint.tabComing")))
|
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(), ""
|
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)
|
case "c": // modal konverter docker run → compose (PRD §5.1.3)
|
||||||
m.modal = newConverterModal()
|
m.modal = newConverterModal()
|
||||||
m.sound.Play(audio.ModalOpen)
|
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":
|
case "u", "s", "R", "p":
|
||||||
if m.view == viewList && m.section == sectionStacks {
|
if m.view == viewList && m.section == sectionStacks {
|
||||||
return m.beginStackAction(key)
|
return m.beginStackAction(key)
|
||||||
|
|||||||
+132
-13
@@ -2,31 +2,48 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
"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/i18n"
|
||||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components"
|
"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/ui/theme"
|
||||||
|
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
// homeBody — dashboard beranda (F-027). Tata letak terinspirasi mirador:
|
// homeGeom — geometri dashboard (satu sumber untuk render & hit-test klik).
|
||||||
// grid panel dengan judul di border; baris atas Jam · Docker · Host,
|
// Baris: 3 kartu atas · area bawah (Kontainer | CTA | Aktivitas) · strip bawah.
|
||||||
// baris bawah Kontainer · Aktivitas.
|
func (m Model) homeGeom() (W, row1H, stripH, row2H, cw, k1, k2 int) {
|
||||||
func (m Model) homeBody() string {
|
W = max(m.width, 60)
|
||||||
W := max(m.width, 60)
|
|
||||||
bodyH := m.bodyHeight()
|
bodyH := m.bodyHeight()
|
||||||
|
|
||||||
row1H := 9
|
row1H, stripH = 9, 5
|
||||||
row2H := bodyH - row1H
|
row2H = bodyH - row1H - stripH
|
||||||
if row2H < 5 {
|
if row2H < 5 {
|
||||||
row1H = 6
|
row1H, stripH = 6, 4
|
||||||
row2H = max(bodyH-row1H, 3)
|
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)
|
w1 := int(float64(W) * 0.36)
|
||||||
w2 := int(float64(W) * 0.30)
|
w2 := int(float64(W) * 0.30)
|
||||||
w3 := W - w1 - w2
|
w3 := W - w1 - w2
|
||||||
@@ -37,16 +54,118 @@ func (m Model) homeBody() string {
|
|||||||
m.hostPanel(w3, row1H),
|
m.hostPanel(w3, row1H),
|
||||||
)
|
)
|
||||||
|
|
||||||
k1 := int(float64(W) * 0.55)
|
left := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
k2 := W - k1
|
|
||||||
row2 := lipgloss.JoinHorizontal(lipgloss.Top,
|
|
||||||
panel(i18n.T("section.containers"), i18n.Tf("fmt.countTotal", len(m.containers)),
|
panel(i18n.T("section.containers"), i18n.Tf("fmt.countTotal", len(m.containers)),
|
||||||
m.miniContainerLines(row2H-2), k1, row2H, false),
|
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)),
|
panel(i18n.T("section.activity"), fmt.Sprintf("(%d)", len(m.activity)),
|
||||||
m.activityLines(k2-4, row2H-2), k2, row2H, false),
|
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 {
|
func (m Model) clockPanel(w, h int) string {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,6 +125,9 @@ func (e *Engine) WaitReady(d time.Duration) bool {
|
|||||||
|
|
||||||
// Status ringkas untuk status bar / `tia-love doctor`.
|
// Status ringkas untuk status bar / `tia-love doctor`.
|
||||||
func (e *Engine) Status() string {
|
func (e *Engine) Status() string {
|
||||||
|
if e == nil { // Engine zero-value aman (Model tanpa audio.New)
|
||||||
|
return "nonaktif"
|
||||||
|
}
|
||||||
e.mu.RLock()
|
e.mu.RLock()
|
||||||
defer e.mu.RUnlock()
|
defer e.mu.RUnlock()
|
||||||
switch {
|
switch {
|
||||||
@@ -144,7 +147,7 @@ func (e *Engine) Status() string {
|
|||||||
// Play memutar efek; fire-and-forget, aman dipanggil dari goroutine mana pun.
|
// Play memutar efek; fire-and-forget, aman dipanggil dari goroutine mana pun.
|
||||||
// No-op bila engine belum siap / dimatikan / muted.
|
// No-op bila engine belum siap / dimatikan / muted.
|
||||||
func (e *Engine) Play(ev Event) {
|
func (e *Engine) Play(ev Event) {
|
||||||
if e.muted || !e.opts.Enabled {
|
if e == nil || e.muted || !e.opts.Enabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
e.mu.RLock()
|
e.mu.RLock()
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ section:
|
|||||||
networks: "Networks"
|
networks: "Networks"
|
||||||
activity: "Activity"
|
activity: "Activity"
|
||||||
detail: "Detail"
|
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:
|
tab:
|
||||||
logs: "Logs"
|
logs: "Logs"
|
||||||
@@ -22,6 +33,13 @@ tab:
|
|||||||
config: "Config"
|
config: "Config"
|
||||||
env: "Env"
|
env: "Env"
|
||||||
top: "Top"
|
top: "Top"
|
||||||
|
action: "Tab actions"
|
||||||
|
|
||||||
|
tabaction:
|
||||||
|
timestamp: "timestamp"
|
||||||
|
follow: "follow tail"
|
||||||
|
bottom: "to bottom"
|
||||||
|
reload: "reload"
|
||||||
|
|
||||||
stats:
|
stats:
|
||||||
history: "CPU history (60s)"
|
history: "CPU history (60s)"
|
||||||
@@ -86,6 +104,9 @@ field:
|
|||||||
inUse: "In use"
|
inUse: "In use"
|
||||||
dir: "Dir"
|
dir: "Dir"
|
||||||
composeFile: "Compose"
|
composeFile: "Compose"
|
||||||
|
version: "Version"
|
||||||
|
repo: "Repo"
|
||||||
|
config: "Config"
|
||||||
|
|
||||||
state:
|
state:
|
||||||
loading: "loading…"
|
loading: "loading…"
|
||||||
@@ -124,8 +145,8 @@ hint:
|
|||||||
inspectWaiting: "fetching inspect…"
|
inspectWaiting: "fetching inspect…"
|
||||||
procsWaiting: "fetching processes…"
|
procsWaiting: "fetching processes…"
|
||||||
footerHome: "[1/enter] stacks · [t] toast · [T] theme · [c] convert · [a] reduced-motion · [q] quit"
|
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"
|
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 · [Tab] focus · [h/l] tab · [c] convert · [r] reload · [0] home · [T] theme · [q] quit"
|
footerContainers: "[1-5] sections · [j/k] select · [/] search · [Tab] focus · [h/l] tab · [T] theme · [r] reload · [0] home · [q] quit"
|
||||||
|
|
||||||
footer:
|
footer:
|
||||||
status: "%s animation %s fps · audio: %s"
|
status: "%s animation %s fps · audio: %s"
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ section:
|
|||||||
networks: "Jaringan"
|
networks: "Jaringan"
|
||||||
activity: "Aktivitas"
|
activity: "Aktivitas"
|
||||||
detail: "Detail"
|
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:
|
tab:
|
||||||
logs: "Log"
|
logs: "Log"
|
||||||
@@ -22,6 +33,13 @@ tab:
|
|||||||
config: "Konfigurasi"
|
config: "Konfigurasi"
|
||||||
env: "Env"
|
env: "Env"
|
||||||
top: "Proses"
|
top: "Proses"
|
||||||
|
action: "Aksi Tab"
|
||||||
|
|
||||||
|
tabaction:
|
||||||
|
timestamp: "timestamp"
|
||||||
|
follow: "ikut ekor"
|
||||||
|
bottom: "ke bawah"
|
||||||
|
reload: "muat ulang"
|
||||||
|
|
||||||
stats:
|
stats:
|
||||||
history: "Riwayat CPU (60 dtk)"
|
history: "Riwayat CPU (60 dtk)"
|
||||||
@@ -86,6 +104,9 @@ field:
|
|||||||
inUse: "Dipakai"
|
inUse: "Dipakai"
|
||||||
dir: "Dir"
|
dir: "Dir"
|
||||||
composeFile: "Compose"
|
composeFile: "Compose"
|
||||||
|
version: "Versi"
|
||||||
|
repo: "Repo"
|
||||||
|
config: "Config"
|
||||||
|
|
||||||
state:
|
state:
|
||||||
loading: "memuat…"
|
loading: "memuat…"
|
||||||
@@ -124,8 +145,8 @@ hint:
|
|||||||
inspectWaiting: "mengambil inspect…"
|
inspectWaiting: "mengambil inspect…"
|
||||||
procsWaiting: "mengambil daftar proses…"
|
procsWaiting: "mengambil daftar proses…"
|
||||||
footerHome: "[1/enter] stack · [t] toast · [T] tema · [c] konversi · [a] reduced-motion · [q] keluar"
|
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"
|
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 · [Tab] fokus · [h/l] tab · [c] konversi · [r] muat ulang · [0] beranda · [T] tema · [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:
|
footer:
|
||||||
status: "%s animasi %s fps · audio: %s"
|
status: "%s animasi %s fps · audio: %s"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user