416 lines
9.8 KiB
Go
416 lines
9.8 KiB
Go
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")
|
|
}
|