layout: bar atas workspace ala mock draf-tia + bar TAB membentang penuh
CI / test (push) Successful in 27s

- 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.
This commit is contained in:
oxpoe committed 2026-10-16 03:39:21 +07:00
1 parent b7866f917b
commit 75ead4be8a
5 files changed
+125 -57

No files matched your search

+30 -4
View File
@@ -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")
+59 -40
View File
@@ -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(), ""
+30 -13
View File
@@ -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(line, i18n.T("toolbar.addStack")) || !strings.Contains(line, i18n.T("toolbar.convert")) {
t.Fatalf("tombol stack/konversi tidak ada: %q", line)
if !strings.Contains(searchLine, "[ / "+i18n.T("toolbar.search")+" ]") {
t.Fatalf("w=%d: pill cari tidak ada: %q", width, searchLine)
}
if w := lipgloss.Width(line); w > 80 {
t.Fatalf("toolbar melebihi lebar layar: %d", w)
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 !strings.Contains(m.toolbarPlain(), "/ abc") {
t.Fatalf("query tidak tampil di pill: %q", m.toolbarPlain())
if _, searchLine = m.workspaceBarPlain(); !strings.Contains(searchLine, "/ abc") {
t.Fatalf("w=%d: query tidak tampil di pill: %q", width, searchLine)
}
}
// 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)
}
}
+3
View File
@@ -5,6 +5,9 @@
nav:
home: "home"
workspace:
title: "workspace"
section:
clock: "Clock"
host: "Host"
+3
View File
@@ -5,6 +5,9 @@
nav:
home: "beranda"
workspace:
title: "workspace"
section:
clock: "Jam"
host: "Host"