From 75ead4be8a9932558c27bcd25d0bbbba693a55d4 Mon Sep 17 00:00:00 2001 From: oxpoe <324556608+oxpoe@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:39:21 +0700 Subject: [PATCH] layout: bar atas workspace ala mock draf-tia + bar TAB membentang penuh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bar workspace jadi panel: judul 'workspace' di border, subjudul daftar seksi (stack, kontainer, image, volume, jaringan), pill Cari + tombol [+ stack] dan [konversi] di kanan — semua klikable (klik = paritas tombol huruf). - Detail kontainer: bar TAB kini membentang penuh di atas TAB content dan kolom Aksi Tab (mock TAB ACTION); hit-test tab & kolom disesuaikan. - Tes: unit TestWorkspaceBar (lebar 40/80/120) + e2e TestWorkspaceBar (subjudul tampil, klik [+ stack] membuka modal); posisi klik e2e lain digeser mengikuti tinggi bar. --- e2e/e2e_test.go | 34 ++++++++++-- internal/app/app.go | 99 +++++++++++++++++++++-------------- internal/app/layout_test.go | 49 +++++++++++------ internal/i18n/locales/en.yaml | 3 ++ internal/i18n/locales/id.yaml | 3 ++ 5 files changed, 128 insertions(+), 60 deletions(-) diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 8a8b137..9068f92 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -22,6 +22,7 @@ import ( "github.com/creack/pty" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker" + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n" ) const ( @@ -711,6 +712,31 @@ func TestTabActionColumn(t *testing.T) { } } +// TestWorkspaceBar — bar atas workspace (mock draf-tia): judul, subjudul +// daftar seksi, dan tombol [+ stack] yang bisa diklik (membuka modal konverter). +func TestWorkspaceBar(t *testing.T) { + sub := strings.ToLower(i18n.T("section.stacks")) + ", " + + strings.ToLower(i18n.T("section.containers")) + ", " + + strings.ToLower(i18n.T("section.images")) + ", " + + strings.ToLower(i18n.T("section.volumes")) + ", " + + strings.ToLower(i18n.T("section.networks")) + u := startTUI(t) + u.waitContains("beranda", 5*time.Second) + u.send("2") + u.waitContains(sub, 5*time.Second) + u.waitContains("workspace", 3*time.Second) + if !u.clickText("+ stack") { + t.Fatal("tombol [+ stack] tidak ditemukan di bar workspace") + } + u.waitContains("Konversi docker run", 3*time.Second) // modal terbuka + u.send("\x03") // Ctrl+C = tutup modal (bukan keluar) + u.waitContains(sub, 3*time.Second) + 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() @@ -877,7 +903,7 @@ func TestMouseAndNavigateKeys(t *testing.T) { u.waitContains("Kontainer", 3*time.Second) u.send("j") u.send("k") - u.send("\x1b[<0;10;4M\x1b[<0;10;4m") // klik kiri di area sidebar + u.send("\x1b[<0;10;8M\x1b[<0;10;8m") // klik kiri di baris daftar sidebar u.send("r") // muat ulang u.send("q") if code := u.waitExit(5 * time.Second); code != 0 { @@ -916,9 +942,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=5 (1-based) → app row 4 → indeks 1 - // (header + toolbar + border panel di atasnya). - u.send("\x1b[<0;10;5M\x1b[<0;10;5m") + // klik baris item ke-2: xterm y=8 (1-based) → app row 7 → indeks 1 + // (header + bar workspace 4 baris + border panel di atasnya). + u.send("\x1b[<0;10;8M\x1b[<0;10;8m") 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 6975886..9bd63e0 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -799,10 +799,11 @@ func (m Model) toastRows() int { return 0 } -// toolRows — baris toolbar workspace (1 di view daftar, 0 di beranda). +// toolRows — bar atas workspace (judul + subjudul + cari + tombol) di view +// daftar; 0 di beranda. func (m Model) toolRows() int { if m.view == viewList { - return 1 + return 4 // border atas · subjudul+tombol · pill cari · border bawah } return 0 } @@ -966,7 +967,7 @@ func (m Model) View() tea.View { } parts = append(parts, header) if m.view == viewList { - parts = append(parts, m.toolbarLine()) + parts = append(parts, m.workspaceBar()) } parts = append(parts, body, footer) @@ -1008,9 +1009,9 @@ func (m *Model) mouseUI(x, y int) (tea.Cmd, bool) { } return nil, false } - // 2) Toolbar workspace (baris di bawah header): pill cari + tombol stack. - if m.view == viewList && y == m.headerRows()-1 { - return m.toolbarKeyAt(x), true + // 2) Bar workspace (di bawah header): cari + tombol stack. + if m.view == viewList && y >= m.headerRows()-3 && y <= m.headerRows()-2 { + return m.workspaceBarKeyAt(x, y), true } // 3) Tab bar detail (view kontainers). if m.view == viewList && m.section == sectionContainers && m.sectionLen() > 0 { @@ -1022,7 +1023,7 @@ func (m *Model) mouseUI(x, y int) (tea.Cmd, bool) { } // 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 { + if row := y - (m.headerRows() + 2); row >= 0 { // 2 = bar TAB + border panel items := m.tabActions() if row < len(items) { return m.handleKey(items[row][0]), true @@ -1055,40 +1056,57 @@ func (m *Model) mouseUI(x, y int) (tea.Cmd, bool) { return nil, false } -// toolbarPlain — toolbar workspace tanpa gaya warna (sumber koordinat hit-test). -func (m Model) toolbarPlain() string { - search := "/ " + i18n.T("toolbar.search") +// workspaceBarParts — potongan bar atas workspace (mock draf-tia): subjudul +// seksi, tombol kanan, pill cari. Satu sumber untuk render & hit-test klik. +func (m Model) workspaceBarParts() (sub, right, search string, gap int) { + w := max(m.width-4, 10) + names := []string{ + i18n.T("section.stacks"), i18n.T("section.containers"), i18n.T("section.images"), + i18n.T("section.volumes"), i18n.T("section.networks"), + } + for i, n := range names { + names[i] = strings.ToLower(n) + } + sub = strings.Join(names, ", ") + right = "[ " + i18n.T("toolbar.addStack") + " ] [ " + i18n.T("toolbar.convert") + " ]" + gap = w - lipgloss.Width(sub) - lipgloss.Width(right) + if gap < 2 { + gap = 2 + sub = truncate(sub, max(w-lipgloss.Width(right)-2, 4)) + } + 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 + return } -func (m Model) toolbarLine() string { +// workspaceBarPlain — bar tanpa gaya warna (sumber koordinat hit-test). +func (m Model) workspaceBarPlain() (subLine, searchLine string) { + sub, right, search, gap := m.workspaceBarParts() + return sub + strings.Repeat(" ", gap) + right, "[ " + search + " ]" +} + +// workspaceBar — bar atas workspace: judul di border, subjudul seksi (kiri) +// + tombol stack/konversi (kanan), pill cari di baris sendiri. +func (m Model) workspaceBar() string { th := theme.Current() - search := "/ " + i18n.T("toolbar.search") - if m.searching { - search = "/ " + m.query + "▌" + sub, right, search, gap := m.workspaceBarParts() + rows := []string{ + dim(sub) + strings.Repeat(" ", gap) + theme.Style(th.Accent).Render(right), + theme.Style(th.Accent).Render("[ " + search + " ]"), } - 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 + return panel(i18n.T("workspace.title"), "", rows, m.width, 4, false) } -// toolbarKeyAt — klik toolbar workspace: pill Cari / tombol stack. -func (m *Model) toolbarKeyAt(x int) tea.Cmd { - label, ok := labelZoneAt(m.toolbarPlain(), x) +// workspaceBarKeyAt — klik di bar workspace: pill Cari / tombol stack. +func (m *Model) workspaceBarKeyAt(x, y int) tea.Cmd { + sub, search := m.workspaceBarPlain() + line := sub + if y == m.headerRows()-2 { + line = search + } + label, ok := labelZoneAt(line, x-2) // bar dalam panel: konten mulai kolom 2 if !ok { return nil } @@ -1250,9 +1268,9 @@ func keyZonesAt(line string, x int) (string, bool) { return "", false } -// tabAt — posisi klik pada baris tab bar → indeks tab. +// tabAt — posisi klik pada baris bar TAB → indeks tab. func (m Model) tabAt(x, y int) (int, bool) { - if y != m.headerRows()+1 { // baris konten pertama = tab bar + if y != m.headerRows() { // bar TAB = baris pertama area konten return 0, false } xl := x - (m.leftWidth() + 2) @@ -1406,11 +1424,12 @@ func (m Model) contentView() string { return panel(s.Name, "", lines, w, h, focused) } - // Kontainer: tab detail + kolom aksi tab di kanan (mock "TAB ACTION"). + // Kontainer: bar TAB membentang penuh di atas tab content + kolom aksi tab + // (mock draf-tia "TAB ACTION"). if m.section == sectionContainers { c := m.containers[idx] wDetail, wAct := m.detailWidths() - lines := []string{m.tabBarLine(wDetail - 4)} + lines := make([]string, 0, h-3) switch m.tab { case tabLogs: lines = append(lines, m.logLines(h-3)...) @@ -1425,12 +1444,12 @@ func (m Model) contentView() string { default: lines = append(lines, "", dim(i18n.T("hint.tabComing"))) } - detail := panel(c.Name, c.State, lines, wDetail, h, focused) + detail := panel(c.Name, c.State, lines, wDetail, h-1, focused) if wAct == 0 { - return detail + return m.tabBarLine(w) + "\n" + detail } - return lipgloss.JoinHorizontal(lipgloss.Top, detail, - panel(i18n.T("tab.action"), "", m.tabActionLines(wAct-4, h-2), wAct, h, focused)) + return m.tabBarLine(w) + "\n" + lipgloss.JoinHorizontal(lipgloss.Top, detail, + panel(i18n.T("tab.action"), "", m.tabActionLines(wAct-4, h-3), wAct, h-1, focused)) } title, meta := m.sectionName(), "" diff --git a/internal/app/layout_test.go b/internal/app/layout_test.go index aadf629..ddd481d 100644 --- a/internal/app/layout_test.go +++ b/internal/app/layout_test.go @@ -27,23 +27,40 @@ func TestDetailWidths(t *testing.T) { } } -// 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) +// TestWorkspaceBar — bar workspace memuat subjudul seksi + tombol stack, +// menampilkan query saat mode cari, dan tidak melebihi lebar dalam panel. +func TestWorkspaceBar(t *testing.T) { + for _, width := range []int{40, 80, 120} { + m := Model{width: width, view: viewList} + subLine, searchLine := m.workspaceBarPlain() + if !strings.Contains(subLine, i18n.T("toolbar.addStack")) || !strings.Contains(subLine, i18n.T("toolbar.convert")) { + t.Fatalf("w=%d: tombol stack/konversi tidak ada: %q", width, subLine) + } + if !strings.Contains(searchLine, "[ / "+i18n.T("toolbar.search")+" ]") { + t.Fatalf("w=%d: pill cari tidak ada: %q", width, searchLine) + } + inner := width - 4 + if w := lipgloss.Width(subLine); w > inner { + t.Fatalf("w=%d: subjudul+tombol lebar %d > %d", width, w, inner) + } + if w := lipgloss.Width(searchLine); w > inner { + t.Fatalf("w=%d: pill cari lebar %d > %d", width, w, inner) + } + m.searching, m.query = true, "abc" + if _, searchLine = m.workspaceBarPlain(); !strings.Contains(searchLine, "/ abc") { + t.Fatalf("w=%d: query tidak tampil di pill: %q", width, searchLine) + } } - 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()) + // Subjudul = daftar seksi (mock): "stack, kontainer, image, volume, network". + m := Model{width: 120, view: viewList} + subLine, _ := m.workspaceBarPlain() + want := strings.ToLower(i18n.T("section.stacks")) + ", " + + strings.ToLower(i18n.T("section.containers")) + ", " + + strings.ToLower(i18n.T("section.images")) + ", " + + strings.ToLower(i18n.T("section.volumes")) + ", " + + strings.ToLower(i18n.T("section.networks")) + if !strings.HasPrefix(subLine, want) { + t.Fatalf("subjudul bar salah: %q (mau %q)", subLine, want) } } diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 4d0780c..910b32b 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -5,6 +5,9 @@ nav: home: "home" +workspace: + title: "workspace" + section: clock: "Clock" host: "Host" diff --git a/internal/i18n/locales/id.yaml b/internal/i18n/locales/id.yaml index 3b603aa..b65f877 100644 --- a/internal/i18n/locales/id.yaml +++ b/internal/i18n/locales/id.yaml @@ -5,6 +5,9 @@ nav: home: "beranda" +workspace: + title: "workspace" + section: clock: "Jam" host: "Host"