feat(ui): modal konverter docker run → compose — pratinjau live, tombol klikable, simpan & jalankan, OSC52 copy
CI / test (push) Successful in 25s

This commit is contained in:
oxpoe committed 2026-10-16 02:20:38 +07:00
1 parent 771f7d2452
commit 8880660ce4
5 files changed
+560 -7

No files matched your search

+44
View File
@@ -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)
+59 -1
View File
@@ -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)
+415
View File
@@ -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")
}
+21 -3
View File
@@ -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…"
+21 -3
View File
@@ -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…"