feat(ui): modal konverter docker run → compose — pratinjau live, tombol klikable, simpan & jalankan, OSC52 copy
CI / test (push) Successful in 25s
CI / test (push) Successful in 25s
This commit is contained in:
1 parent
771f7d2452
commit
8880660ce4
5 files changed
+560
-7
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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…"
|
||||
|
||||
@@ -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…"
|
||||
|
||||
Reference in new issue
Block a user