layout: subjudul bar workspace jadi tautan seksi (klik = pindah seksi)
CI / test (push) Successful in 28s

Nama seksi di subjudul (stack, kontainer, image, volume, jaringan) kini
bergaya aksen dan bisa diklik → pindah seksi, paritas tombol 1-5. Rentang
kolom tiap nama dihitung dari satu sumber (workspaceSubsections) sehingga
render & hit-test tidak bisa melenceng; layar sempit memotong nama yang
tidak muat. Tes: unit (span → seksi, batas sempit) + e2e (klik 'image').
This commit is contained in:
oxpoe committed 2026-10-16 03:49:44 +07:00
1 parent 75ead4be8a
commit 7af6d48a80
3 files changed
+104 -18

No files matched your search

+6
View File
@@ -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)
+67 -17
View File
@@ -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
}
+31 -1
View File
@@ -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: