From 8880660ce47c8cfca28f70da07f6acd9ad826529 Mon Sep 17 00:00:00 2001 From: oxpoe <324556608+oxpoe@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:20:38 +0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20modal=20konverter=20docker=20run=20?= =?UTF-8?q?=E2=86=92=20compose=20=E2=80=94=20pratinjau=20live,=20tombol=20?= =?UTF-8?q?klikable,=20simpan=20&=20jalankan,=20OSC52=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/e2e_test.go | 44 ++++ internal/app/app.go | 60 ++++- internal/app/modal.go | 415 ++++++++++++++++++++++++++++++++++ internal/i18n/locales/en.yaml | 24 +- internal/i18n/locales/id.yaml | 24 +- 5 files changed, 560 insertions(+), 7 deletions(-) create mode 100644 internal/app/modal.go diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 3da1242..9189ab5 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -670,6 +670,50 @@ func TestStackDown(t *testing.T) { } } +// TestConverterModal — modal konverter end-to-end TANPA docker: buka `c`, +// ketik perintah, Enter → pratinjau, klik [Simpan] → file compose.yaml +// benar-benar tertulis di stacks_dir dan stack muncul di seksi Stacks. +func TestConverterModal(t *testing.T) { + xdg := t.TempDir() + stacksDir := filepath.Join(xdg, "stacks") + 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("c") + u.waitContains("Konversi docker run", 3*time.Second) + u.send("docker run -p 80:80 nginx") + u.send("\r") // Enter → hitung pratinjau, fokus pindah ke nama + u.waitContains("Nama stack", 3*time.Second) + u.waitContains("services:", 3*time.Second) + + if !u.clickText("Simpan") { + t.Fatal("tombol Simpan tidak ditemukan") + } + u.waitContains("tersimpan", 5*time.Second) + + out, err := os.ReadFile(filepath.Join(stacksDir, "nginx", "compose.yaml")) + if err != nil { + t.Fatalf("compose.yaml tidak tertulis: %v", err) + } + if !strings.Contains(string(out), "80:80") || !strings.Contains(string(out), "image: nginx") { + t.Fatalf("isi compose salah:\n%s", out) + } + + u.send("1") // seksi Stacks kini memuat stack baru + u.waitContains("nginx", 3*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/internal/app/app.go b/internal/app/app.go index 677e18d..23d1939 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -159,6 +159,8 @@ type Model struct { stackConfirm string // nama stack menunggu konfirmasi turunkan (D) stackActID int64 + modal *converterModal + containers []docker.Container images []docker.Image volumes []docker.Volume @@ -452,6 +454,38 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch(cmds...) + case saveStackMsg: + if msg.err != nil { + if m.modal != nil { + m.modal.notice = msg.err.Error() + } + m.sound.Play(audio.NotifyError) + return m, nil + } + m.modal = nil + m.sound.Play(audio.NotifySuccess) + m.toast.Show(components.LevelSuccess, i18n.Tf("toast.stackSaved", msg.name), msg.dir) + m.stacksID++ + cmds := []tea.Cmd{fetchStacks(m.stacksID, m.compose, m.stacksDir)} + if msg.run { + m.stackActID++ + m.stackBusy = msg.name + m.stackBusyAct = "u" + cmds = append(cmds, runStackAction(m.stackActID, m.compose, + stack.Stack{Name: msg.name, Dir: msg.dir}, "u")) + } + return m, tea.Batch(cmds...) + + case copyYAMLMsg: + if msg.err != nil { + m.toast.Show(components.LevelError, i18n.T("toast.copyFail"), msg.err.Error()) + m.sound.Play(audio.NotifyError) + return m, nil + } + m.toast.Show(components.LevelInfo, i18n.T("toast.yamlCopied"), "") + m.sound.Play(audio.NotifySuccess) + return m, nil + case eventMsg: if msg.id != m.eventsID { return m, nil @@ -496,9 +530,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width, m.height = msg.Width, msg.Height case tea.KeyPressMsg: + if m.modal != nil { + return m, m.modalKeyMsg(msg) // modal menangkap semua tombol + } return m, m.handleKey(msg.String()) + + case tea.PasteMsg: + if m.modal != nil { + m.modalPaste(msg.String()) + } case tea.MouseClickMsg: if msg.Button == tea.MouseLeft { + if m.modal != nil { + return m, m.modalClick(msg.X, msg.Y) // klik masuk ke modal saja + } if m.toast.Active() { m.toast = components.NewToast() // klik = tutup toast } @@ -525,6 +570,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.MouseWheelMsg: + if m.modal != nil { // gulir pratinjau di modal + if msg.Button == tea.MouseWheelUp && m.modal.prevScroll > 0 { + m.modal.prevScroll-- + } else if msg.Button == tea.MouseWheelDown && m.modal.prevScroll < max(0, len(m.modal.preview)-1) { + m.modal.prevScroll++ + } + return m, nil + } if m.view != viewList || m.focus != focusContent { return m, nil } @@ -891,7 +944,9 @@ func (m Model) View() tea.View { } parts = append(parts, header, body, footer) - v := tea.NewView(strings.Join(parts, "\n")) + content := m.overlayModal(strings.Join(parts, "\n")) + + v := tea.NewView(content) v.AltScreen = true v.MouseMode = tea.MouseModeCellMotion return v @@ -1590,6 +1645,9 @@ func (m *Model) handleKey(key string) tea.Cmd { if m.view == viewList && m.section == sectionContainers { return m.cycleTab(1) } + case "c": // modal konverter docker run → compose (PRD §5.1.3) + m.modal = newConverterModal() + m.sound.Play(audio.ModalOpen) case "u", "s", "R", "p": if m.view == viewList && m.section == sectionStacks { return m.beginStackAction(key) diff --git a/internal/app/modal.go b/internal/app/modal.go new file mode 100644 index 0000000..8e0f4ee --- /dev/null +++ b/internal/app/modal.go @@ -0,0 +1,415 @@ +package app + +import ( + "encoding/base64" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio" + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n" + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/stack" + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/theme" +) + +// converterModal — modal konversi docker run → compose (PRD §5.1.3). +// Mouse-native: semua tombol bisa diklik; keyboard: Enter/Tab/Ctrl+S/Ctrl+R/Esc. +type converterModal struct { + input string // perintah mentah (multi-baris dari paste) + name string // nama stack tujuan + focus int // 0 = input, 1 = nama + preview []string + previewErr string + computedFor string // input saat pratinjau dihitung (deteksi basi) + prevScroll int + notice string // pesan validasi (mis. nama terpakai) +} + +const modalInputLines = 5 + +func newConverterModal() *converterModal { return &converterModal{} } + +// compute — jalankan konverter untuk pratinjau. +func (c *converterModal) compute() { + cmd := strings.TrimSpace(c.input) + c.computedFor = c.input + c.preview, c.previewErr, c.notice = nil, "", "" + c.prevScroll = 0 + if cmd == "" { + return + } + res, err := stack.Convert(cmd) + if err != nil { + c.previewErr = err.Error() + return + } + c.preview = strings.Split(res.Output, "\n") + if strings.TrimSpace(c.name) == "" { + c.name = res.ServiceName + } +} + +func (c *converterModal) stale() bool { return c.computedFor != c.input } + +func (c *converterModal) previewText() string { + if len(c.preview) == 0 { + return "" + } + return strings.Join(c.preview, "\n") + "\n" +} + +// --- pesan & perintah --- + +type saveStackMsg struct { + name string + dir string + run bool + err error +} + +type copyYAMLMsg struct{ err error } + +var stackNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +// saveFromModal — validasi di UI, penulisan file di dalam tea.Cmd (async). +func (m *Model) saveFromModal(run bool) tea.Cmd { + c := m.modal + if c == nil { + return nil + } + if c.stale() { + c.compute() + } + if len(c.preview) == 0 || c.previewErr != "" { + c.notice = i18n.T("modal.invalid") + return nil + } + name := strings.TrimSpace(c.name) + if !stackNameRE.MatchString(name) { + c.notice = i18n.T("modal.badName") + return nil + } + dir := filepath.Join(m.stacksDir, name) + content := c.previewText() + return func() tea.Msg { + if _, err := os.Stat(dir); err == nil { + return saveStackMsg{name: name, dir: dir, run: run, err: fmt.Errorf(i18n.T("modal.nameTaken"), name)} + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return saveStackMsg{name: name, dir: dir, run: run, err: err} + } + if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte(content), 0o644); err != nil { + return saveStackMsg{name: name, dir: dir, run: run, err: err} + } + return saveStackMsg{name: name, dir: dir, run: run} + } +} + +// copyYAML — salin via OSC52 (bekerja juga lewat SSH/tmux; tanpa dependency). +func copyYAML(text string) tea.Cmd { + return func() tea.Msg { + if text == "" { + return copyYAMLMsg{err: fmt.Errorf("pratinjau kosong")} + } + seq := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\a" + _, err := os.Stdout.WriteString(seq) + return copyYAMLMsg{err: err} + } +} + +// --- input keyboard di modal --- + +func (m *Model) modalKeyMsg(msg tea.KeyPressMsg) tea.Cmd { + c := m.modal + if msg.Mod&tea.ModCtrl != 0 { + switch msg.Code { + case 's': + return m.saveFromModal(false) + case 'r': + return m.saveFromModal(true) + case 'c': + m.closeModal() + } + return nil + } + + switch msg.Code { + case tea.KeyEsc: + m.closeModal() + case tea.KeyEnter: + if c.focus == 0 { + c.compute() + c.focus = 1 + } else { + return m.saveFromModal(false) + } + case tea.KeyTab: + c.focus = 1 - c.focus + case tea.KeyBackspace: + c.backspace() + case tea.KeyUp: + if c.prevScroll > 0 { + c.prevScroll-- + } + case tea.KeyDown: + if c.prevScroll < max(0, len(c.preview)-1) { + c.prevScroll++ + } + default: + if msg.Text != "" { + c.typeText(msg.Text) + } + } + return nil +} + +func (c *converterModal) typeText(t string) { + c.notice = "" + if c.focus == 0 { + c.input += t + return + } + c.name += strings.NewReplacer("\n", "", " ", "").Replace(t) // nama: tanpa spasi/baris +} + +func (c *converterModal) backspace() { + del := func(s string) string { + r := []rune(s) + if len(r) == 0 { + return s + } + return string(r[:len(r)-1]) + } + if c.focus == 0 { + c.input = del(c.input) + } else { + c.name = del(c.name) + } +} + +// modalPaste — teks tempel (bisa multi-baris) masuk ke field yang fokus. +func (m *Model) modalPaste(text string) { + c := m.modal + if c == nil || text == "" { + return + } + c.typeText(text) +} + +func (m *Model) closeModal() { + m.modal = nil + m.sound.Play(audio.ModalClose) +} + +// --- geometri & hit-test --- + +func (c *converterModal) geom(w, h int) (top, left, boxW, boxH int) { + boxW = min(100, max(w-6, 30)) + boxH = min(26, max(h-4, 12)) + top = max(0, (h-boxH)/2) + left = max(0, (w-boxW)/2) + return +} + +// layout — baris-baris konten (0-based, relatif terhadap isi panel). +func (c *converterModal) layout(boxH int) (prevH, nameRow, btnRow, hintRow int) { + inner := boxH - 2 + prevH = max(3, inner-11) + nameRow = 1 + modalInputLines + 2 + prevH + btnRow = nameRow + 1 + hintRow = btnRow + 1 + return +} + +func (c *converterModal) buttonsPlain() string { + parts := []string{ + "[ " + i18n.T("modal.btnCopy") + " ]", + "[ " + i18n.T("modal.btnSave") + " ]", + "[ " + i18n.T("modal.btnRun") + " ]", + "[ " + i18n.T("modal.btnCancel") + " ]", + } + return strings.Join(parts, " ") +} + +// modalClick — klik: field → fokus; tombol → aksi; luar kotak → tutup. +func (m *Model) modalClick(x, y int) tea.Cmd { + c := m.modal + top, left, boxW, boxH := c.geom(m.width, m.height) + m.sound.Play(audio.Click) + if x < left || x >= left+boxW || y < top || y >= top+boxH { + m.closeModal() + return nil + } + row := y - (top + 1) + _, nameRow, btnRow, _ := c.layout(boxH) + switch { + case row >= 1 && row < 1+modalInputLines: + c.focus = 0 + case row == nameRow: + c.focus = 1 + case row == btnRow: + if act, ok := labelZoneAt(c.buttonsPlain(), x-left-2); ok { + switch act { + case i18n.T("modal.btnCopy"): + return copyYAML(c.previewText()) + case i18n.T("modal.btnSave"): + return m.saveFromModal(false) + case i18n.T("modal.btnRun"): + return m.saveFromModal(true) + case i18n.T("modal.btnCancel"): + m.closeModal() + } + } + } + return nil +} + +// labelZoneAt — cari segmen "[ label ]" pada baris; kembalikan label (trim). +func labelZoneAt(line string, x int) (string, bool) { + runes := []rune(line) + for i := 0; i < len(runes); i++ { + if runes[i] != '[' { + continue + } + j := i + 1 + for j < len(runes) && runes[j] != ']' { + j++ + } + if j >= len(runes) { + return "", false + } + end := j + 1 + for end < len(runes) && runes[end] != '[' { + end++ + } + if x >= i && x < end { + return strings.TrimSpace(string(runes[i+1 : j])), true + } + i = j + } + return "", false +} + +// --- render --- + +func (c *converterModal) view(w, h int) string { + _, _, boxW, boxH := c.geom(w, h) + inner := boxW - 4 + prevH, _, _, _ := c.layout(boxH) + + rows := []string{dim(i18n.T("modal.inputLabel"))} + rows = append(rows, c.inputLines(inner)...) + + label := dim(i18n.T("modal.previewLabel")) + switch { + case c.notice != "": + label += " " + red(c.notice) + case c.stale(): + label += " " + dim(i18n.T("modal.noPreview")) + } + rows = append(rows, "", label) + rows = append(rows, c.previewLines(prevH)...) + + rows = append(rows, c.nameLine(), c.buttonsLine(), dim(i18n.T("modal.hint"))) + return panel(i18n.T("modal.convertTitle"), "esc", rows, boxW, boxH, true) +} + +func (c *converterModal) inputLines(inner int) []string { + shown := c.input + if c.focus == 0 { + shown += "▌" + } else { + shown += " " + } + lines := wrapText(shown, max(10, inner)) + if len(lines) > modalInputLines { + lines = lines[len(lines)-modalInputLines:] // cursor selalu terlihat + } + for len(lines) < modalInputLines { + lines = append(lines, "") + } + out := make([]string, 0, len(lines)) + for _, l := range lines { + out = append(out, text(l)) + } + return out +} + +func (c *converterModal) previewLines(rows int) []string { + out := make([]string, 0, rows) + switch { + case c.previewErr != "": + out = append(out, red(truncate(c.previewErr, 120))) + case len(c.preview) == 0: + out = append(out, dim(i18n.T("modal.noPreview"))) + default: + for i := c.prevScroll; i < len(c.preview) && len(out) < rows; i++ { + out = append(out, dim(c.preview[i])) + } + } + // WAJIB setinggi alokasi: panel() mem-pad di bawah, bukan di dalam blok — + // tanpa ini baris nama/tombol bergeser dari hit-test (bug nyata). + for len(out) < rows { + out = append(out, "") + } + return out +} + +func (c *converterModal) nameLine() string { + marker := " " + style := theme.Style(theme.Current().Text) + if c.focus == 1 { + marker = theme.Style(theme.Current().Accent).Render("▶ ") + } + val := c.name + if c.focus == 1 { + val += "▌" + } + return marker + dim(i18n.T("modal.nameLabel")+" ") + style.Render("[ "+val+" ]") +} + +func (c *converterModal) buttonsLine() string { + return theme.Style(theme.Current().Accent).Render(c.buttonsPlain()) +} + +// wrapText — bungkus teks per karakter ke lebar w (pertahankan newline). +func wrapText(s string, w int) []string { + var out []string + for _, raw := range strings.Split(s, "\n") { + r := []rune(raw) + if len(r) == 0 { + out = append(out, "") + continue + } + for len(r) > w { + out = append(out, string(r[:w])) + r = r[w:] + } + out = append(out, string(r)) + } + return out +} + +// overlayModal — timpa baris tengah layar dengan kotak modal. +func (m Model) overlayModal(base string) string { + if m.modal == nil { + return base + } + W, H := max(m.width, 20), max(m.height, 10) + top, _, _, _ := m.modal.geom(W, H) + box := strings.Split(m.modal.view(W, H), "\n") + lines := strings.Split(base, "\n") + for i, bl := range box { + row := top + i + if row < 0 || row >= len(lines) { + continue + } + lines[row] = lipgloss.PlaceHorizontal(W, lipgloss.Center, bl) + } + return strings.Join(lines, "\n") +} diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 5b9a2e7..1342de7 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -40,6 +40,21 @@ stack: busy: "⏳ %s…" mouseHint: "Every button in this panel is mouse-clickable." +modal: + convertTitle: "⇄ Convert docker run → compose" + inputLabel: "Paste the docker run command:" + previewLabel: "Preview (compose.yaml):" + noPreview: "press Enter to preview…" + nameLabel: "Stack name:" + btnCopy: "Copy" + btnSave: "Save" + btnRun: "Save & Run" + btnCancel: "Cancel" + hint: "Enter preview · Tab switch field · Ctrl+S save · Ctrl+R save & run · Esc close" + badName: "invalid stack name (letters/digits/._- only)" + nameTaken: "stack '%s' already exists" + invalid: "command not valid yet" + host: local: "local" @@ -108,9 +123,9 @@ hint: statsWaiting: "fetching stats…" inspectWaiting: "fetching inspect…" procsWaiting: "fetching processes…" - 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" + footerHome: "[1/enter] stacks · [t] toast · [T] theme · [c] convert · [a] reduced-motion · [q] quit" + footerStacks: "[u] start · [s] stop · [R] restart · [p] update · [D] down · [c] convert · [j/k] select · [0] home · [q] quit" + footerContainers: "[1-5] sections · [j/k] select · [Tab] focus · [h/l] tab · [c] convert · [r] reload · [0] home · [T] theme · [q] quit" footer: status: "%s animation %s fps · audio: %s" @@ -124,6 +139,9 @@ toast: timestamps: "Log timestamps: %s" stackOk: "Stack '%s': %s done" stackFail: "Stack '%s' failed" + stackSaved: "Stack '%s' saved" + yamlCopied: "YAML copied to clipboard (OSC52)" + copyFail: "Copy failed" activity: waiting: "waiting for Docker events…" diff --git a/internal/i18n/locales/id.yaml b/internal/i18n/locales/id.yaml index e7cbc06..0ad2426 100644 --- a/internal/i18n/locales/id.yaml +++ b/internal/i18n/locales/id.yaml @@ -40,6 +40,21 @@ stack: busy: "⏳ %s…" mouseHint: "Semua tombol di panel ini bisa diklik mouse." +modal: + convertTitle: "⇄ Konversi docker run → compose" + inputLabel: "Tempel perintah docker run:" + previewLabel: "Pratinjau (compose.yaml):" + noPreview: "tekan Enter untuk pratinjau…" + nameLabel: "Nama stack:" + btnCopy: "Salin" + btnSave: "Simpan" + btnRun: "Simpan & Jalankan" + btnCancel: "Batal" + hint: "Enter pratinjau · Tab ganti field · Ctrl+S simpan · Ctrl+R simpan & jalankan · Esc tutup" + badName: "nama stack tidak valid (huruf/angka/._- saja)" + nameTaken: "stack '%s' sudah ada" + invalid: "perintah belum valid" + host: local: "lokal" @@ -108,9 +123,9 @@ hint: statsWaiting: "mengambil statistik…" inspectWaiting: "mengambil inspect…" procsWaiting: "mengambil daftar proses…" - 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" + footerHome: "[1/enter] stack · [t] toast · [T] tema · [c] konversi · [a] reduced-motion · [q] keluar" + footerStacks: "[u] jalankan · [s] henti · [R] ulang · [p] perbarui · [D] turunkan · [c] konversi · [j/k] pilih · [0] beranda · [q] keluar" + footerContainers: "[1-5] bagian · [j/k] pilih · [Tab] fokus · [h/l] tab · [c] konversi · [r] muat ulang · [0] beranda · [T] tema · [q] keluar" footer: status: "%s animasi %s fps · audio: %s" @@ -124,6 +139,9 @@ toast: timestamps: "Timestamp log: %s" stackOk: "Stack '%s': %s selesai" stackFail: "Stack '%s' gagal" + stackSaved: "Stack '%s' tersimpan" + yamlCopied: "YAML disalin ke clipboard (OSC52)" + copyFail: "Gagal menyalin" activity: waiting: "menunggu event Docker…"