CI / test (push) Successful in 27s
Analisa referensi tuios (Charm/BubbleTea): overlay = surface solid + chip judul aksen, bukan border; hint strip dibuang bertier saat sempit; geometri satu sumber untuk render & hit-test. - components.Chip: pill label (latar aksen + teks Base; mode terminal = reverse) — dipakai bar workspace & modal. - Bar workspace jadi surface 3 baris (chip 'workspace', subjudul tautan seksi + tombol kanan, pill Cari inset Base) — pola mock draf-tia. - Modal konverter jadi surface: chip judul, blok inset Base untuk input & output (padding & teks dalam satu gaya agar latar tak bolong oleh reset SGR), strip tombol Base, baris hint terstyling. - Footer jadi dock hint: kunci aksen tebal + label redup, hint dibuang bertier (dekoratif dulu, esensial bertahan) dengan ellipsis, status diagnostik dipangkas ke sisa ruang — teks plain (hit-test) = teks tampil. - Tes: chip (3 mode tema), footer bertier (plain==styled, batas lebar), e2e posisi klik digeser mengikuti bar 3 baris.
465 lines
12 KiB
Go
465 lines
12 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/components"
|
|
"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(28, max(h-4, 16))
|
|
top = max(0, (h-boxH)/2)
|
|
left = max(0, (w-boxW)/2)
|
|
return
|
|
}
|
|
|
|
// layout — baris-baris konten (0-based, relatif terhadap blok modal):
|
|
// chip · kosong · label input · blok input · kosong · label output · blok
|
|
// output · nama · tombol · hint.
|
|
func (c *converterModal) layout(boxH int) (inputTop, inputRows, prevTop, prevH, nameRow, btnRow, hintRow int) {
|
|
inputTop, inputRows = 3, modalInputLines
|
|
prevH = max(3, boxH-13)
|
|
prevTop = inputTop + inputRows + 2
|
|
nameRow = prevTop + 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
|
|
inputTop, inputRows, _, _, nameRow, btnRow, _ := c.layout(boxH)
|
|
switch {
|
|
case row >= inputTop && row < inputTop+inputRows:
|
|
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 ---
|
|
|
|
// view — modal sebagai surface (pola tuios overlay): chip judul, blok inset
|
|
// (latar Base) untuk input & output, strip tombol, baris hint tombol.
|
|
func (c *converterModal) view(w, h int) string {
|
|
th := theme.Current()
|
|
_, _, boxW, boxH := c.geom(w, h)
|
|
inner := boxW - 4 // padding 2 sel tiap sisi (pola tuios)
|
|
inputTop, inputRows, prevTop, prevH, nameRow, btnRow, hintRow := c.layout(boxH)
|
|
|
|
rows := make([]string, boxH)
|
|
put := func(i int, s string) {
|
|
if i >= 0 && i < boxH {
|
|
rows[i] = s
|
|
}
|
|
}
|
|
// block — satu baris blok inset: teks + padding dalam SATU gaya (latar Base
|
|
// + warna teks) supaya latar tidak bolong karena reset SGR di tengah baris.
|
|
block := func(text string, fg theme.Color) string {
|
|
row := " " + text
|
|
if gap := inner - lipgloss.Width(row); gap > 0 {
|
|
row += strings.Repeat(" ", gap)
|
|
}
|
|
return onPaint(th.Base, "", " ") +
|
|
styleOn(th.Base, fg).Render(ansiTruncate(row, inner)) +
|
|
onPaint(th.Base, "", " ")
|
|
}
|
|
|
|
put(0, onPaint(th.Surface, "", " ")+components.Chip(i18n.T("modal.convertTitle")))
|
|
put(inputTop-1, onPaint(th.Surface, th.Subtext, " "+i18n.T("modal.inputLabel")))
|
|
|
|
inLines := c.inputLines(inner - 2)
|
|
for i := 0; i < inputRows; i++ {
|
|
put(inputTop+i, block(inLines[i], th.Text))
|
|
}
|
|
|
|
put(prevTop-1, onPaint(th.Surface, th.Subtext, " "+i18n.T("modal.previewLabel"))+c.previewNote())
|
|
pvLevel := th.Subtext
|
|
if c.previewErr != "" {
|
|
pvLevel = th.Danger
|
|
}
|
|
pv := c.previewLines(prevH)
|
|
for i := 0; i < prevH; i++ {
|
|
put(prevTop+i, block(pv[i], pvLevel))
|
|
}
|
|
|
|
put(nameRow, onPaint(th.Surface, "", " ")+c.nameLine(th.Surface))
|
|
put(btnRow, fill(onPaint(th.Base, "", " ")+
|
|
styleOn(th.Base, th.Accent).Render(c.buttonsPlain()), boxW, th.Base))
|
|
put(hintRow, onPaint(th.Surface, "", " ")+
|
|
hintStyledText(parseHints(i18n.T("modal.hint")), false, th.Surface))
|
|
|
|
out := make([]string, 0, boxH)
|
|
for _, r := range rows {
|
|
out = append(out, fill(r, boxW, th.Surface))
|
|
}
|
|
return strings.Join(out, "\n")
|
|
}
|
|
|
|
// previewNote — catatan status pratinjau (notice/stale) di baris label.
|
|
func (c *converterModal) previewNote() string {
|
|
th := theme.Current()
|
|
switch {
|
|
case c.notice != "":
|
|
return onPaint(th.Surface, th.Danger, " "+c.notice)
|
|
case c.stale():
|
|
return onPaint(th.Surface, th.Subtext, " "+i18n.T("modal.noPreview"))
|
|
}
|
|
return ""
|
|
}
|
|
|
|
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, "")
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// previewLines — baris pratinjau mentah (tanpa gaya); warna dipasang oleh blok
|
|
// inset lewat previewErr → Danger, sisanya Subtext.
|
|
func (c *converterModal) previewLines(rows int) []string {
|
|
out := make([]string, 0, rows)
|
|
switch {
|
|
case c.previewErr != "":
|
|
out = append(out, truncate(c.previewErr, 120))
|
|
case len(c.preview) == 0:
|
|
out = append(out, i18n.T("modal.noPreview"))
|
|
default:
|
|
for i := c.prevScroll; i < len(c.preview) && len(out) < rows; i++ {
|
|
out = append(out, c.preview[i])
|
|
}
|
|
}
|
|
// WAJIB setinggi alokasi: baris nama/tombol harus tetap di posisi yang
|
|
// dihitung layout() (hit-test klik bergantung padanya — bug nyata).
|
|
for len(out) < rows {
|
|
out = append(out, "")
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (c *converterModal) nameLine(bg theme.Color) string {
|
|
th := theme.Current()
|
|
marker := onPaint(bg, "", " ")
|
|
if c.focus == 1 {
|
|
marker = onPaint(bg, th.Accent, "▶ ")
|
|
}
|
|
val := c.name
|
|
if c.focus == 1 {
|
|
val += "▌"
|
|
}
|
|
return marker + onPaint(bg, th.Subtext, i18n.T("modal.nameLabel")+" ") + onPaint(bg, th.Text, "[ "+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")
|
|
}
|