layout: bar atas workspace ala mock draf-tia + bar TAB membentang penuh
CI / test (push) Successful in 27s
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:
1 parent
b7866f917b
commit
75ead4be8a
5 files changed
+125
-57
No files matched your search
+30
-4
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
nav:
|
||||
home: "home"
|
||||
|
||||
workspace:
|
||||
title: "workspace"
|
||||
|
||||
section:
|
||||
clock: "Clock"
|
||||
host: "Host"
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
nav:
|
||||
home: "beranda"
|
||||
|
||||
workspace:
|
||||
title: "workspace"
|
||||
|
||||
section:
|
||||
clock: "Jam"
|
||||
host: "Host"
|
||||
|
||||
Reference in new issue
Block a user