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

+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)