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
+232
-57
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user