feat(stacks): aksi stack klikable (u/s/R/p/D) + konfirmasi turunkan — plus fix zona tombol & emulator DECSTBM/IL/DL
CI / test (push) Successful in 26s

This commit is contained in:
oxpoe committed 2026-10-16 01:58:12 +07:00
1 parent f57d1cc29c
commit 1b9a406aa7
6 files changed
+410 -24

No files matched your search

+64
View File
@@ -31,6 +31,10 @@ const (
var binPath string
// dumpFile — bila TIA_E2E_DUMP di-set, aliran pty mentah ditulis ke file ini
// (alat bedah saat emulator layar perlu dianalisis).
var dumpFile *os.File
func TestMain(m *testing.M) {
tmp, err := os.MkdirTemp("", "tia-e2e-*")
if err != nil {
@@ -91,6 +95,11 @@ func startTUIEnv(t *testing.T, env ...string) *tui {
t.Skip("e2e pty tidak didukung di Windows")
}
cmd := exec.Command(binPath)
if p := os.Getenv("TIA_E2E_DUMP"); p != "" {
if f, err := os.Create(p); err == nil {
dumpFile = f
}
}
// Kunci kapabilitas terminal: tanpa ini, runner CI ber-TERM "dumb"
// membuat lipgloss menonaktifkan warna & latar → asersi highlight flaky.
base := []string{"TERM=xterm-256color", "COLORTERM=truecolor"}
@@ -110,6 +119,9 @@ func startTUIEnv(t *testing.T, env ...string) *tui {
u.mu.Lock()
u.scr.write(buf[:n])
u.mu.Unlock()
if dumpFile != nil {
_, _ = dumpFile.Write(buf[:n])
}
}
if err != nil {
return
@@ -606,6 +618,58 @@ func TestConvertCommand(t *testing.T) {
}
}
// TestStackDown — aksi stack via toolbar klikable: [D] → konfirmasi → [y]
// menjalankan `compose down`. Tanpa pull image: stack berisi service kosong.
func TestStackDown(t *testing.T) {
if _, err := os.Stat("/var/run/docker.sock"); err != nil {
t.Skip("docker socket tidak ada")
}
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker CLI tidak ada")
}
xdg := t.TempDir()
stacksDir := filepath.Join(xdg, "stacks")
if err := os.MkdirAll(filepath.Join(stacksDir, "demo"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(stacksDir, "demo", "compose.yaml"),
[]byte("services: {}\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(xdg, "tia"), 0o755); err != nil {
t.Fatal(err)
}
cfg := "version: 1\nstacks_dir: " + stacksDir + "\nui:\n language: id\n"
if err := os.WriteFile(filepath.Join(xdg, "tia", "config.yaml"), []byte(cfg), 0o644); err != nil {
t.Fatal(err)
}
u := startTUIEnv(t, "XDG_CONFIG_HOME="+xdg)
u.waitContains("beranda", 5*time.Second)
u.send("1")
u.waitContains("demo", 5*time.Second)
u.waitContains("[u]", 3*time.Second) // toolbar klikable tampil
// klik [D] → konfirmasi muncul; batalkan dengan keyboard (n)
if !u.clickText("[D]") {
t.Fatal("tombol [D] tidak ditemukan")
}
u.waitContains("Turunkan stack", 3*time.Second)
u.send("n")
u.waitContains("[u]", 3*time.Second) // kembali ke toolbar
// jalankan sungguhan: D → y → toast "selesai"
u.send("D")
u.waitContains("Turunkan stack", 3*time.Second)
u.send("y")
u.waitContains("selesai", 15*time.Second)
u.send("q")
if code := u.waitExit(5 * time.Second); code != 0 {
t.Fatalf("exit code %d, mau 0", code)
}
}
func TestThemeCycle(t *testing.T) {
u := startTUI(t)
u.waitContains("beranda", 5*time.Second)
+120 -8
View File
@@ -27,6 +27,8 @@ type screen struct {
savR, savC int
bg bool // status SGR latar saat ini
mTop, mBot int // scroll region (DECSTBM), 0-based inklusif
st int // 0 normal · 1 esc · 2 esc-intern · 3 csi · 4 osc · 5 osc-esc
seq []byte
@@ -50,7 +52,7 @@ func newScreen(rows, cols int) *screen {
g[i][j] = cell{r: ' '}
}
}
return &screen{rows: rows, cols: cols, grid: g}
return &screen{rows: rows, cols: cols, grid: g, mBot: rows - 1}
}
func (s *screen) write(b []byte) {
@@ -63,9 +65,12 @@ func (s *screen) write(b []byte) {
case c == '\r':
s.curC = 0
case c == '\n':
s.curR++
if s.curR >= s.mBot {
s.scrollUp(1) // menggulir dalam scroll region (seperti terminal asli)
} else {
s.curR++
}
s.curC = 0
s.clampCursor()
case c == '\b':
if s.curC > 0 {
s.curC--
@@ -139,9 +144,12 @@ func (s *screen) applySimple(c rune) {
case 'D': // index (turun 1 baris)
s.curR++
s.clampCursor()
case 'M': // reverse index (naik 1 baris)
s.curR--
s.clampCursor()
case 'M': // reverse index (naik 1 baris; di batas atas region → scroll turun)
if s.curR <= s.mTop {
s.scrollDown(1)
} else {
s.curR--
}
case 'E': // baris berikutnya, kolom 0
s.curR++
s.curC = 0
@@ -210,16 +218,52 @@ func (s *screen) applyCSI(final rune, params string) {
}
case 'K':
s.eraseLine(at(nums, 0, 0))
case 'X': // erase n karakter (kursor tidak pindah)
case 'X': // erase n karakter (kursor tidak pindah) — ikut latar aktif (BCE)
n := at(nums, 0, 1)
for i := 0; i < n && s.curC+i < s.cols; i++ {
s.grid[s.curR][s.curC+i] = cell{r: ' '}
s.grid[s.curR][s.curC+i] = cell{r: ' ', bg: s.bg}
}
case 'L': // insert n baris (dipakai renderer saat insertAbove); bukan urutan mouse
if priv == 0 && s.curR >= s.mTop && s.curR <= s.mBot {
s.insertLines(at(nums, 0, 1))
}
case 'M': // delete n baris; ESC[<…M adalah KLIK MOUSE (SGR) — jangan tertukar
if priv == 0 && s.curR >= s.mTop && s.curR <= s.mBot {
s.deleteLines(at(nums, 0, 1))
}
case 'S': // scroll layar naik n baris
if priv == 0 {
s.scrollUp(at(nums, 0, 1))
}
case 'T': // scroll layar turun n baris
if priv == 0 {
s.scrollDown(at(nums, 0, 1))
}
case 'r': // DECSTBM — set scroll region (dipakai renderer saat menggeser layar)
if priv == 0 {
top := at(nums, 0, 1) - 1
bot := at(nums, 1, s.rows) - 1
if top < 0 {
top = 0
}
if bot >= s.rows {
bot = s.rows - 1
}
if bot > top {
s.mTop, s.mBot = top, bot
} else {
s.mTop, s.mBot = 0, s.rows-1
}
s.curR, s.curC = 0, 0 // spesifikasi: kursor ke home setelah set margin
}
case 'h':
if priv == '?' && at(nums, 0, 0) == 1049 { // masuk alt-screen
s.clearAll()
}
case 'm':
if priv == '<' {
return // ESC[<…m = release klik mouse (SGR), bukan SGR gaya
}
// Lacak SGR latar: 48 (set bg) · 0/49 (reset). Dipakai sel & Highlighted().
if len(nums) == 0 {
s.bg = false
@@ -280,6 +324,74 @@ func (s *screen) clearAll() {
}
}
// insertLines — sisipkan n baris kosong di baris kursor DALAM scroll region;
// baris di bawahnya bergeser turun (kelebihan di batas bawah terbuang).
func (s *screen) insertLines(n int) {
if n <= 0 || n > s.mBot-s.curR+1 {
n = s.mBot - s.curR + 1
}
for r := s.mBot; r >= s.curR+n; r-- {
copy(s.grid[r], s.grid[r-n])
}
for r := s.curR; r < s.curR+n && r <= s.mBot; r++ {
for c := range s.grid[r] {
s.grid[r][c] = cell{r: ' '}
}
}
}
// deleteLines — hapus n baris mulai baris kursor DALAM scroll region.
func (s *screen) deleteLines(n int) {
if n <= 0 || n > s.mBot-s.curR+1 {
n = s.mBot - s.curR + 1
}
for r := s.curR; r <= s.mBot; r++ {
if r+n <= s.mBot {
copy(s.grid[r], s.grid[r+n])
} else {
for c := range s.grid[r] {
s.grid[r][c] = cell{r: ' '}
}
}
}
}
// scrollUp — gulir scroll region naik n baris (baris bawah region jadi kosong).
func (s *screen) scrollUp(n int) {
if n <= 0 {
return
}
if n > s.mBot-s.mTop+1 {
n = s.mBot - s.mTop + 1
}
for r := s.mTop; r <= s.mBot-n; r++ {
copy(s.grid[r], s.grid[r+n])
}
for r := s.mBot - n + 1; r <= s.mBot; r++ {
for c := range s.grid[r] {
s.grid[r][c] = cell{r: ' '}
}
}
}
// scrollDown — gulir scroll region turun n baris (baris atas region jadi kosong).
func (s *screen) scrollDown(n int) {
if n <= 0 {
return
}
if n > s.mBot-s.mTop+1 {
n = s.mBot - s.mTop + 1
}
for r := s.mBot; r >= s.mTop+n; r-- {
copy(s.grid[r], s.grid[r-n])
}
for r := s.mTop; r < s.mTop+n; r++ {
for c := range s.grid[r] {
s.grid[r][c] = cell{r: ' '}
}
}
}
// String — snapshot layar (spasi kanan dipangkas per baris).
func (s *screen) String() string {
lines := make([]string, s.rows)
+104 -14
View File
@@ -152,6 +152,11 @@ type Model struct {
stacksID int64
stacksErr error
stackBusy string // nama stack yang sedang menjalankan aksi
stackBusyAct string // kode aksi berjalan (u/s/R/p/D)
stackConfirm string // nama stack menunggu konfirmasi turunkan (D)
stackActID int64
containers []docker.Container
images []docker.Image
volumes []docker.Volume
@@ -421,6 +426,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case stackActionMsg:
if msg.id != m.stackActID {
return m, nil
}
m.stackBusy = ""
// Aksi stack memengaruhi kontainer & status → segarkan keduanya.
cmds := []tea.Cmd{}
if m.compose != nil {
m.stacksID++
cmds = append(cmds, fetchStacks(m.stacksID, m.compose, m.stacksDir))
}
if m.cl != nil {
cmds = append(cmds, loadAll(m.cl)...)
}
if msg.err != nil {
m.toast.Show(components.LevelError, i18n.Tf("toast.stackFail", msg.name), lastLine(msg.out+" "+msg.err.Error()))
m.sound.Play(audio.NotifyError)
} else {
m.toast.Show(components.LevelSuccess, i18n.Tf("toast.stackOk", msg.name, msg.label), lastLine(msg.out))
m.sound.Play(audio.NotifySuccess)
}
return m, tea.Batch(cmds...)
case eventMsg:
if msg.id != m.eventsID {
return m, nil
@@ -869,7 +897,11 @@ func (m Model) View() tea.View {
func (m Model) footerPlain() string {
hints := i18n.T("hint.footerHome")
if m.view == viewList {
hints = i18n.T("hint.footerContainers")
if m.section == sectionStacks {
hints = i18n.T("hint.footerStacks")
} else {
hints = i18n.T("hint.footerContainers")
}
}
anim := "—"
if m.animating {
@@ -898,7 +930,22 @@ func (m *Model) mouseUI(x, y int) tea.Cmd {
return m.restartData()
}
}
// 3) Panel beranda (baris kontainer & panel Docker).
// 3) Toolbar aksi / tombol konfirmasi stack (baris konten pertama detail).
if m.view == viewList && m.section == sectionStacks && m.sectionLen() > 0 && y == m.headerRows()+1 {
line := ""
switch {
case m.stackConfirm != "":
line = m.stackConfirmLine()
case m.stackBusy == "":
line = m.stackToolbar()
}
if line != "" {
if key, ok := keyZonesAt(line, x-m.leftWidth()-2); ok {
return m.handleKey(key)
}
}
}
// 4) Panel beranda (baris kontainer & panel Docker).
if m.view == viewHome {
if cmd := m.homeClick(x, y); cmd != nil {
return cmd
@@ -908,8 +955,12 @@ func (m *Model) mouseUI(x, y int) tea.Cmd {
}
// footerKeyAt — posisi rune x berada di segmen hint mana; kembalikan tombolnya.
func (m *Model) footerKeyAt(x int) (string, bool) {
runes := []rune(m.footerPlain())
func (m *Model) footerKeyAt(x int) (string, bool) { return keyZonesAt(m.footerPlain(), x) }
// keyZonesAt — cari segmen "[k] …" pada satu baris; posisi rune x → tombol k.
// Dipakai bersama oleh hint footer, toolbar stack, dan tombol konfirmasi.
func keyZonesAt(line string, x int) (string, bool) {
runes := []rune(line)
for i := 0; i < len(runes); i++ {
if runes[i] != '[' {
continue
@@ -922,13 +973,13 @@ func (m *Model) footerKeyAt(x int) (string, bool) {
return "", false
}
end := j + 1
for end < len(runes) && runes[end] != '·' { // segmen sampai pemisah
end++
for end < len(runes) && runes[end] != '·' && runes[end] != '[' {
end++ // segmen: sampai pemisah "·" ATAU bracket tombol berikutnya
}
if x >= i && x < end {
k := strings.Split(string(runes[i+1:j]), "/")[0]
if dash := strings.IndexByte(k, '-'); dash > 0 {
k = k[:dash] // "1-4" → "1"
k = k[:dash] // "1-5" → "1"
}
if strings.EqualFold(k, "tab") {
k = "tab"
@@ -1106,20 +1157,30 @@ func (m Model) contentView() string {
idx := min(m.cur(), m.sectionLen()-1)
// Stack: ringkasan + berkas (aksi menyusul).
// Stack: ringkasan + toolbar aksi (klikable; aksi async + refresh).
if m.section == sectionStacks {
if m.stacksErr != nil {
return panel(m.sectionName(), "", []string{red(truncate(m.stacksErr.Error(), w-6))}, w, h, focused)
}
s := m.stacks[idx]
glyph, color := stackStyle(s.Status)
return panel(s.Name, "", []string{
field(i18n.T("field.dir")) + dim(truncate(s.Dir, w-12)),
field(i18n.T("field.composeFile")) + dim(truncate(s.ComposeFile, w-18)),
field(i18n.T("field.status")) + styled(color, glyph+" "+stackStatusText(s.Status)),
lines := []string{}
switch {
case m.stackConfirm == s.Name:
lines = append(lines, red(i18n.Tf("stack.confirmDown", s.Name)), m.stackConfirmLine(), "")
case m.stackBusy == s.Name:
lines = append(lines, dim(i18n.Tf("stack.busy", i18n.T("stack."+mapActionName(m.stackBusyAct)))), "")
default:
lines = append(lines, m.stackToolbar(), "")
}
lines = append(lines,
field(i18n.T("field.dir"))+dim(truncate(s.Dir, w-12)),
field(i18n.T("field.composeFile"))+dim(truncate(s.ComposeFile, w-18)),
field(i18n.T("field.status"))+styled(color, glyph+" "+stackStatusText(s.Status)),
"",
dim(i18n.T("hint.stackLater")),
}, w, h, focused)
dim(i18n.T("stack.mouseHint")),
)
return panel(s.Name, "", lines, w, h, focused)
}
// Kontainer: tab detail (Log berfungsi; sisanya menyusul Phase 2).
@@ -1417,6 +1478,25 @@ func colorizeLog(line string) string {
// handleKey — SATU jalur aksi, dipakai keyboard DAN klik mouse
// (tab bar, hint footer, baris beranda). Tambah aksi = tambah case di sini.
func (m *Model) handleKey(key string) tea.Cmd {
// Konfirmasi destruktif (turunkan stack) menangkap input lebih dulu.
if m.stackConfirm != "" {
switch key {
case "y":
name := m.stackConfirm
m.stackConfirm = ""
for i, s := range m.stacks {
if s.Name == name {
m.setCur(i)
return m.beginStackAction("D")
}
}
case "n", "esc":
m.stackConfirm = ""
m.sound.Play(audio.ModalClose)
}
return nil
}
switch key {
case "q", "ctrl+c":
m.stopLogs()
@@ -1507,6 +1587,16 @@ func (m *Model) handleKey(key string) tea.Cmd {
if m.view == viewList && m.section == sectionContainers {
return m.cycleTab(1)
}
case "u", "s", "R", "p":
if m.view == viewList && m.section == sectionStacks {
return m.beginStackAction(key)
}
case "D":
if m.view == viewList && m.section == sectionStacks && m.sectionLen() > 0 && m.stackBusy == "" {
s := m.stacks[min(m.cur(), m.sectionLen()-1)]
m.stackConfirm = s.Name
m.sound.Play(audio.NotifyWarning)
}
case "G": // ekor log (live) / dasar dokumen (config & env)
if m.view == viewList && m.focus == focusContent {
switch m.tab {
+92
View File
@@ -2,10 +2,12 @@ package app
import (
"context"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/stack"
@@ -36,6 +38,96 @@ func fetchStacks(id int64, cm *docker.ComposeClient, dir string) tea.Cmd {
}
}
// stackActionMsg — hasil menjalankan aksi compose untuk sebuah stack.
type stackActionMsg struct {
id int64
name string
label string
out string
err error
}
// mapActionName — kode tombol → nama aksi (kunci katalog stack.*).
func mapActionName(k string) string {
switch k {
case "u":
return "up"
case "s":
return "stop"
case "R":
return "restart"
case "p":
return "pull"
default:
return "down"
}
}
// beginStackAction — jalankan aksi async untuk stack terpilih (toolbar/keys).
func (m *Model) beginStackAction(act string) tea.Cmd {
if m.section != sectionStacks || m.view != viewList || m.sectionLen() == 0 || m.stackBusy != "" {
return nil
}
s := m.stacks[min(m.cur(), m.sectionLen()-1)]
m.stackActID++
m.stackBusy = s.Name
m.stackBusyAct = act
m.sound.Play(audio.Exec)
return runStackAction(m.stackActID, m.compose, s, act)
}
// runStackAction — eksekusi verb compose (perbarui = pull lalu up).
func runStackAction(id int64, cm *docker.ComposeClient, s stack.Stack, act string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
var out string
var err error
switch act {
case "u":
out, err = cm.Up(ctx, s.Dir)
case "s":
out, err = cm.Stop(ctx, s.Dir)
case "R":
out, err = cm.Restart(ctx, s.Dir)
case "p": // perbarui: pull image terbaru lalu recreate
out, err = cm.Pull(ctx, s.Dir)
if err == nil {
var upOut string
upOut, err = cm.Up(ctx, s.Dir)
out += upOut
}
case "D":
out, err = cm.Down(ctx, s.Dir)
}
return stackActionMsg{id: id, name: s.Name, label: i18n.T("stack." + mapActionName(act)), out: out, err: err}
}
}
// stackToolbar — tombol aksi (klikable via keyZones — sama dengan footer).
func (m Model) stackToolbar() string {
keys := []string{"u", "s", "R", "p", "D"}
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, "["+k+"] "+i18n.T("stack."+mapActionName(k)))
}
return strings.Join(parts, " ")
}
// stackConfirmLine — tombol konfirmasi destruktif (klikable).
func (m Model) stackConfirmLine() string {
return "[y] " + i18n.T("stack.yes") + " [n] " + i18n.T("stack.no")
}
// lastLine — baris terakhir output (untuk ringkasan toast).
func lastLine(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
if len(lines) == 0 {
return ""
}
return truncate(lines[len(lines)-1], 120)
}
// stackStyle — glyph + warna per status stack.
func stackStyle(st stack.Status) (string, theme.Color) {
th := theme.Current()
+15 -1
View File
@@ -28,6 +28,18 @@ stats:
read: "Read"
write: "Write"
stack:
up: "Start"
stop: "Stop"
restart: "Restart"
pull: "Update"
down: "Down"
yes: "Yes, bring down"
no: "Cancel"
confirmDown: "Bring down stack '%s'?"
busy: "⏳ %s…"
mouseHint: "Every button in this panel is mouse-clickable."
host:
local: "local"
@@ -96,8 +108,8 @@ hint:
statsWaiting: "fetching stats…"
inspectWaiting: "fetching inspect…"
procsWaiting: "fetching processes…"
stackLater: "Stack actions (start · stop · pull) coming next."
footerHome: "[1/enter] stacks · [t] toast · [T] theme · [a] reduced-motion · [q] quit"
footerStacks: "[u] start · [s] stop · [R] restart · [p] update · [D] down · [j/k] select · [0] home · [q] quit"
footerContainers: "[1-5] sections · [j/k] select · [Tab] focus · [h/l] tab · [r] reload · [0] home · [T] theme · [q] quit"
footer:
@@ -110,6 +122,8 @@ toast:
configError: "⚠ [ WARNING ] Config problem — using defaults"
themeMode: "Theme mode: %s"
timestamps: "Log timestamps: %s"
stackOk: "Stack '%s': %s done"
stackFail: "Stack '%s' failed"
activity:
waiting: "waiting for Docker events…"
+15 -1
View File
@@ -28,6 +28,18 @@ stats:
read: "Baca"
write: "Tulis"
stack:
up: "Jalankan"
stop: "Hentikan"
restart: "Ulang"
pull: "Perbarui"
down: "Turunkan"
yes: "Ya, turunkan"
no: "Batal"
confirmDown: "Turunkan stack '%s'?"
busy: "⏳ %s…"
mouseHint: "Semua tombol di panel ini bisa diklik mouse."
host:
local: "lokal"
@@ -96,8 +108,8 @@ hint:
statsWaiting: "mengambil statistik…"
inspectWaiting: "mengambil inspect…"
procsWaiting: "mengambil daftar proses…"
stackLater: "Aksi stack (jalankan · henti · pull) menyusul."
footerHome: "[1/enter] stack · [t] toast · [T] tema · [a] reduced-motion · [q] keluar"
footerStacks: "[u] jalankan · [s] henti · [R] ulang · [p] perbarui · [D] turunkan · [j/k] pilih · [0] beranda · [q] keluar"
footerContainers: "[1-5] bagian · [j/k] pilih · [Tab] fokus · [h/l] tab · [r] muat ulang · [0] beranda · [T] tema · [q] keluar"
footer:
@@ -110,6 +122,8 @@ toast:
configError: "⚠ [ PERINGATAN ] Config bermasalah — memakai default"
themeMode: "Mode tema: %s"
timestamps: "Timestamp log: %s"
stackOk: "Stack '%s': %s selesai"
stackFail: "Stack '%s' gagal"
activity:
waiting: "menunggu event Docker…"