diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 9068f92..321431c 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -731,6 +731,12 @@ func TestWorkspaceBar(t *testing.T) { 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) + + // Tautan seksi di subjudul: klik "image" → pindah ke seksi Image. + if !u.clickText("image") { + t.Fatal("tautan seksi 'image' tidak ditemukan di subjudul") + } + u.waitContains("Image", 3*time.Second) // judul panel sidebar = seksi Image u.send("q") if code := u.waitExit(5 * time.Second); code != 0 { t.Fatalf("exit code %d, mau 0", code) diff --git a/internal/app/app.go b/internal/app/app.go index 9bd63e0..3a06b5f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1056,23 +1056,42 @@ func (m *Model) mouseUI(x, y int) (tea.Cmd, bool) { return nil, false } +// workspaceSubsections — subjudul bar = daftar seksi workspace, plus rentang +// kolom (rune) tiap nama untuk klik → pindah seksi. +func (m Model) workspaceSubsections() (sub string, spans [][2]int) { + keys := []string{"stacks", "containers", "images", "volumes", "networks"} + for i, k := range keys { + if i > 0 { + sub += ", " + } + name := strings.ToLower(i18n.T("section." + k)) + start := len([]rune(sub)) + sub += name + spans = append(spans, [2]int{start, start + len([]rune(name))}) + } + return +} + // 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) { +func (m Model) workspaceBarParts() (sub string, spans [][2]int, 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, ", ") + sub, spans = m.workspaceSubsections() 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)) + limit := max(w-lipgloss.Width(right)-2, 4) + if limit < len([]rune(sub)) { + sub = truncate(sub, limit) + cut, kept := len([]rune(sub)), spans[:0] + for _, sp := range spans { + if sp[0] < cut { // nama yang terpotong tetap bisa diklik sebatas yang tampak + kept = append(kept, [2]int{sp[0], min(sp[1], cut)}) + } + } + spans = kept + } } search = "/ " + i18n.T("toolbar.search") if m.searching { @@ -1083,30 +1102,61 @@ func (m Model) workspaceBarParts() (sub, right, search string, gap int) { // workspaceBarPlain — bar tanpa gaya warna (sumber koordinat hit-test). func (m Model) workspaceBarPlain() (subLine, searchLine string) { - sub, right, search, gap := m.workspaceBarParts() + 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. +// workspaceSectionAt — kolom x (relatif isi bar) → seksi di subjudul. +func (m Model) workspaceSectionAt(x int) (section, bool) { + _, spans, _, _, _ := m.workspaceBarParts() + for i, sp := range spans { + if x >= sp[0] && x < sp[1] { + return section(i), true + } + } + return 0, false +} + +// workspaceBar — bar atas workspace: judul di border, subjudul seksi (nama +// seksi = tautan, klik = pindah seksi) + tombol stack/konversi, pill cari. func (m Model) workspaceBar() string { th := theme.Current() - sub, right, search, gap := m.workspaceBarParts() + sub, spans, right, search, gap := m.workspaceBarParts() + + runes := []rune(sub) + var b strings.Builder + prev := 0 + for _, sp := range spans { + b.WriteString(dim(string(runes[prev:sp[0]]))) + b.WriteString(theme.Style(th.Accent).Render(string(runes[sp[0]:sp[1]]))) + prev = sp[1] + } + b.WriteString(dim(string(runes[prev:]))) + rows := []string{ - dim(sub) + strings.Repeat(" ", gap) + theme.Style(th.Accent).Render(right), + b.String() + strings.Repeat(" ", gap) + theme.Style(th.Accent).Render(right), theme.Style(th.Accent).Render("[ " + search + " ]"), } return panel(i18n.T("workspace.title"), "", rows, m.width, 4, false) } -// workspaceBarKeyAt — klik di bar workspace: pill Cari / tombol stack. +// workspaceBarKeyAt — klik di bar workspace: nama seksi, pill Cari, tombol stack. func (m *Model) workspaceBarKeyAt(x, y int) tea.Cmd { + x -= 2 // bar dalam panel: konten mulai kolom 2 sub, search := m.workspaceBarPlain() + if y == m.headerRows()-3 { + if s, ok := m.workspaceSectionAt(x); ok { + m.jumpSection(s) + m.searching, m.query = false, "" + m.sound.Play(audio.Click) + return m.restartData() + } + } line := sub if y == m.headerRows()-2 { line = search } - label, ok := labelZoneAt(line, x-2) // bar dalam panel: konten mulai kolom 2 + label, ok := labelZoneAt(line, x) if !ok { return nil } diff --git a/internal/app/layout_test.go b/internal/app/layout_test.go index ddd481d..3ba9440 100644 --- a/internal/app/layout_test.go +++ b/internal/app/layout_test.go @@ -51,7 +51,7 @@ func TestWorkspaceBar(t *testing.T) { t.Fatalf("w=%d: query tidak tampil di pill: %q", width, searchLine) } } - // Subjudul = daftar seksi (mock): "stack, kontainer, image, volume, network". + // Subjudul = daftar seksi (mock): "stack, kontainer, image, volume, jaringan". m := Model{width: 120, view: viewList} subLine, _ := m.workspaceBarPlain() want := strings.ToLower(i18n.T("section.stacks")) + ", " + @@ -62,6 +62,36 @@ func TestWorkspaceBar(t *testing.T) { if !strings.HasPrefix(subLine, want) { t.Fatalf("subjudul bar salah: %q (mau %q)", subLine, want) } + + // Tautan seksi: tiap span menunjuk nama seksi yang benar & bisa diklik. + keys := []string{"stacks", "containers", "images", "volumes", "networks"} + sub, spans := m.workspaceSubsections() + runes := []rune(sub) + if len(spans) != len(keys) { + t.Fatalf("jumlah span = %d, mau %d", len(spans), len(keys)) + } + for i, sp := range spans { + got, wantName := string(runes[sp[0]:sp[1]]), strings.ToLower(i18n.T("section."+keys[i])) + if got != wantName { + t.Fatalf("span %d = %q, mau %q", i, got, wantName) + } + if s, ok := m.workspaceSectionAt(sp[0]); !ok || int(s) != i { + t.Fatalf("klik kolom %d harus → seksi %d (ok=%v s=%d)", sp[0], i, ok, s) + } + } + if _, ok := m.workspaceSectionAt(len(runes) + 5); ok { + t.Fatal("kolom di luar subjudul tidak boleh memetakan seksi") + } + + // Layar sempit: span tetap di dalam subjudul yang tampak. + narrow := Model{width: 40, view: viewList} + subNarrow, spansNarrow, _, _, _ := narrow.workspaceBarParts() + limit := len([]rune(subNarrow)) + for _, sp := range spansNarrow { + if sp[0] < 0 || sp[1] > limit { + t.Fatalf("span %v di luar subjudul tampak (%d kolom)", sp, limit) + } + } } // TestPanelTitleFits — judul/meta panjang tidak boleh melebihi lebar panel: