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
CI / test (push) Successful in 26s
This commit is contained in:
1 parent
f57d1cc29c
commit
1b9a406aa7
6 files changed
+410
-24
No files matched your search
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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…"
|
||||
|
||||
@@ -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…"
|
||||
|
||||
Reference in new issue
Block a user