diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 05e23e3..6571626 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -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) diff --git a/e2e/screen_test.go b/e2e/screen_test.go index 69802d3..240b67b 100644 --- a/e2e/screen_test.go +++ b/e2e/screen_test.go @@ -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) diff --git a/internal/app/app.go b/internal/app/app.go index 4d7f04a..91b56b6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 { diff --git a/internal/app/stacks.go b/internal/app/stacks.go index cb5e8e9..075d357 100644 --- a/internal/app/stacks.go +++ b/internal/app/stacks.go @@ -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() diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 96f3c19..14c26fb 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -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…" diff --git a/internal/i18n/locales/id.yaml b/internal/i18n/locales/id.yaml index 2ccf631..16f690d 100644 --- a/internal/i18n/locales/id.yaml +++ b/internal/i18n/locales/id.yaml @@ -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…"