feat(stack): converter docker run→compose (port REA modul 1) — 19 golden vektor + CLI tia-love convert
CI / test (push) Successful in 24s

This commit is contained in:
oxpoe committed 2026-10-16 01:42:02 +07:00
1 parent f555f27467
commit b84afdb494
11 files changed
+1519 -159

No files matched your search

+288 -150
View File
@@ -432,157 +432,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width, m.height = msg.Width, msg.Height
case tea.KeyPressMsg:
switch msg.String() {
case "q", "ctrl+c":
m.stopLogs()
if m.eventsCancel != nil {
m.eventsCancel()
}
m.sound.Play(audio.ModalClose)
return m, tea.Quit
case "esc":
if m.view == viewHome {
m.sound.Play(audio.ModalClose)
return m, tea.Quit
}
if m.focus == focusContent {
m.focus = focusSidebar
m.sound.Play(audio.Click)
break
}
m.view = viewHome
m.stopLogs()
m.sound.Play(audio.Click)
case "0":
m.view = viewHome
m.stopLogs()
m.sound.Play(audio.Click)
case "1", "enter":
m.jumpSection(sectionContainers)
m.sound.Play(audio.Click)
return m, m.restartData()
case "2":
m.jumpSection(sectionImages)
m.sound.Play(audio.Click)
return m, m.restartData()
case "3":
m.jumpSection(sectionVolumes)
m.sound.Play(audio.Click)
return m, m.restartData()
case "4":
m.jumpSection(sectionNetworks)
m.sound.Play(audio.Click)
return m, m.restartData()
case "a": // reduced motion
m.animating = !m.animating
if m.animating {
m.tickID = animation.NewID()
return m, animation.Next(m.tickID)
}
case "r": // muat ulang semua data docker + stream event
if m.cl == nil {
break
}
m.loading = true
m.sound.Play(audio.Click)
cmds := append(loadAll(m.cl), m.restartData())
cmds = append(cmds, m.startEvents())
return m, tea.Batch(cmds...)
case "T": // tombol cepat tema (PRD §5.4): auto → dark → light → terminal
cur := theme.Current()
next := theme.CycleMode(cur.Mode)
theme.Set(next, cur.AccentName)
m.toast.Show(components.LevelInfo,
i18n.Tf("toast.themeMode", i18n.T("theme."+string(next))),
i18n.T("theme.switchHint"))
m.sound.Play(audio.Click)
case "t": // demo toast (sampai modal asli datang)
m.toast.Show(components.LevelSuccess, i18n.T("toast.demoTitle"), i18n.T("toast.demoBody"))
m.sound.Play(audio.NotifySuccess)
case "tab":
if m.view == viewList {
if m.focus == focusSidebar {
m.focus = focusContent
} else {
m.focus = focusSidebar
}
m.sound.Play(audio.Click)
}
case "h", "left", "[":
if m.view == viewList && m.section == sectionContainers {
return m, m.cycleTab(-1)
}
case "l", "right", "]":
if m.view == viewList && m.section == sectionContainers {
return m, m.cycleTab(1)
}
case "G": // ekor log (live) / dasar dokumen (config & env)
if m.view == viewList && m.focus == focusContent {
switch m.tab {
case tabConfig, tabEnv:
m.detailScroll = m.detailScrollMax()
default:
m.logOffset = 0
}
m.sound.Play(audio.Click)
}
case "S": // toggle timestamp log
m.showTimestamps = !m.showTimestamps
label := i18n.T("state.off")
if m.showTimestamps {
label = i18n.T("state.on")
}
m.toast.Show(components.LevelInfo, i18n.Tf("toast.timestamps", label), i18n.T("theme.switchHint"))
m.sound.Play(audio.Click)
return m, m.restartData()
case "j", "down":
if m.view == viewList && m.focus == focusContent {
switch m.tab {
case tabConfig, tabEnv:
if m.detailScroll < m.detailScrollMax() {
m.detailScroll++
}
default: // tab Log
if m.logOffset > 0 {
m.logOffset--
}
}
m.sound.Play(audio.Navigate)
break
}
if c := m.cur(); c < m.sectionLen()-1 {
m.setCur(c + 1)
}
m.sound.Play(audio.Navigate)
return m, m.restartData()
case "k", "up":
if m.view == viewList && m.focus == focusContent {
switch m.tab {
case tabConfig, tabEnv:
if m.detailScroll > 0 {
m.detailScroll--
}
default: // tab Log
if m.logOffset < len(m.logs)-1 {
m.logOffset++
}
}
m.sound.Play(audio.Navigate)
break
}
if c := m.cur(); c > 0 {
m.setCur(c - 1)
}
m.sound.Play(audio.Navigate)
return m, m.restartData()
}
return m, m.handleKey(msg.String())
case tea.MouseClickMsg:
if msg.Button == tea.MouseLeft {
m.sound.Play(audio.Click)
if m.toast.Active() {
m.toast = components.NewToast() // klik = tutup toast
}
// Elemen UI eksplisit dulu (F-022): hint footer, tab bar, panel beranda.
if cmd := m.mouseUI(msg.X, msg.Y); cmd != nil {
return m, cmd
}
m.sound.Play(audio.Click)
if m.view == viewList {
if msg.X >= m.leftWidth() {
m.focus = focusContent
@@ -951,12 +811,11 @@ func (m Model) View() tea.View {
case viewHome:
header += dim(" · " + i18n.T("nav.home"))
body = m.homeBody()
footer = m.footerLine(i18n.T("hint.footerHome"))
default:
header += dim(" · " + i18n.T("nav.containers"))
body = lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView())
footer = m.footerLine(i18n.T("hint.footerContainers"))
}
footer = m.footerLine()
parts := []string{}
if t := m.toast.View(max(m.width, 20)); t != "" {
@@ -970,12 +829,141 @@ func (m Model) View() tea.View {
return v
}
func (m Model) footerLine(hints string) string {
func (m Model) footerPlain() string {
hints := i18n.T("hint.footerHome")
if m.view == viewList {
hints = i18n.T("hint.footerContainers")
}
anim := "—"
if m.animating {
anim = fmt.Sprintf("%d", m.fps)
}
return dim(truncate(i18n.Tf("footer.status", hints, anim, m.sound.Status()), m.width))
return truncate(i18n.Tf("footer.status", hints, anim, m.sound.Status()), m.width)
}
func (m Model) footerLine() string { return dim(m.footerPlain()) }
// mouseUI — hit-test elemen UI eksplisit terhadap mouse (F-022). Semua yang
// tampak seperti tombol/tab/hint HARUS sampai di sini.
func (m *Model) mouseUI(x, y int) tea.Cmd {
// 1) Hint footer: klik "[k]" (atau labelnya) menjalankan aksi k.
if y == m.height-2 {
if key, ok := m.footerKeyAt(x); ok {
return m.handleKey(key)
}
}
// 2) Tab bar detail (view kontainers).
if m.view == viewList && m.section == sectionContainers && m.sectionLen() > 0 {
if idx, ok := m.tabAt(x, y); ok {
m.tab = idx
m.focus = focusContent
m.sound.Play(audio.Click)
return m.restartData()
}
}
// 3) Panel beranda (baris kontainer & panel Docker).
if m.view == viewHome {
if cmd := m.homeClick(x, y); cmd != nil {
return cmd
}
}
return nil
}
// footerKeyAt — posisi rune x berada di segmen hint mana; kembalikan tombolnya.
func (m *Model) footerKeyAt(x int) (string, bool) {
runes := []rune(m.footerPlain())
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] != '·' { // segmen sampai pemisah
end++
}
if x >= i && x < end {
k := strings.Split(string(runes[i+1:j]), "/")[0]
if dash := strings.IndexByte(k, '-'); dash > 0 {
k = k[:dash] // "1-4" → "1"
}
if strings.EqualFold(k, "tab") {
k = "tab"
}
return k, true
}
i = j
}
return "", false
}
// tabAt — posisi klik pada baris tab bar → indeks tab.
func (m Model) tabAt(x, y int) (int, bool) {
if y != m.headerRows()+1 { // baris konten pertama = tab bar
return 0, false
}
xl := x - (m.leftWidth() + 2)
if xl < 0 {
return 0, false
}
labels := []string{
i18n.T("tab.logs"), i18n.T("tab.stats"), i18n.T("tab.config"),
i18n.T("tab.env"), i18n.T("tab.top"),
}
pos := 0
for i, l := range labels {
w := len([]rune(l))
if i == m.tab {
w += 2 // tanda aktif: [Label]
}
if xl >= pos && xl < pos+w {
return i, true
}
pos += w + 2 // pemisah " "
}
return 0, false
}
// homeClick — panel beranda: baris kontainer → buka workspace pada item itu;
// klik panel Docker → buka bagian kontainer.
func (m *Model) homeClick(x, y int) tea.Cmd {
W := max(m.width, 60)
bodyH := m.bodyHeight()
row1H := 9
row2H := bodyH - row1H
if row2H < 5 {
row1H = 6
row2H = max(bodyH-row1H, 3)
}
w1 := int(float64(W) * 0.36)
w2 := int(float64(W) * 0.30)
k1 := int(float64(W) * 0.55)
top := m.headerRows() + row1H // border atas panel baris-2
first := top + 1 // konten baris pertama (daftar kontainer)
switch {
case y >= first && y < first+max(row2H-2, 0) && x < k1:
idx := y - first
if len(m.containers) == 0 || idx >= len(m.containers) {
return nil
}
m.jumpSection(sectionContainers)
m.setCur(idx)
m.sound.Play(audio.Click)
return m.restartData()
case y >= m.headerRows()+1 && y < m.headerRows()+row1H && x >= w1 && x < w1+w2:
m.jumpSection(sectionContainers)
m.sound.Play(audio.Click)
return m.restartData()
}
return nil
}
func (m Model) sidebarView() string {
@@ -1368,3 +1356,153 @@ func colorizeLog(line string) string {
return theme.Style(c).Bold(true).Render(tok)
})
}
// handleKey — SATU jalur aksi, dipakai keyboard DAN klik mouse
// (tab bar, hint footer, baris beranda). Tambah aksi = tambah case di sini.
func (m *Model) handleKey(key string) tea.Cmd {
switch key {
case "q", "ctrl+c":
m.stopLogs()
if m.eventsCancel != nil {
m.eventsCancel()
}
m.sound.Play(audio.ModalClose)
return tea.Quit
case "esc":
if m.view == viewHome {
m.sound.Play(audio.ModalClose)
return tea.Quit
}
if m.focus == focusContent {
m.focus = focusSidebar
m.sound.Play(audio.Click)
break
}
m.view = viewHome
m.stopLogs()
m.sound.Play(audio.Click)
case "0":
m.view = viewHome
m.stopLogs()
m.sound.Play(audio.Click)
case "1", "enter":
m.jumpSection(sectionContainers)
m.sound.Play(audio.Click)
return m.restartData()
case "2":
m.jumpSection(sectionImages)
m.sound.Play(audio.Click)
return m.restartData()
case "3":
m.jumpSection(sectionVolumes)
m.sound.Play(audio.Click)
return m.restartData()
case "4":
m.jumpSection(sectionNetworks)
m.sound.Play(audio.Click)
return m.restartData()
case "a": // reduced motion
m.animating = !m.animating
if m.animating {
m.tickID = animation.NewID()
return animation.Next(m.tickID)
}
case "r": // muat ulang semua data docker + stream event
if m.cl == nil {
break
}
m.loading = true
m.sound.Play(audio.Click)
cmds := append(loadAll(m.cl), m.restartData())
cmds = append(cmds, m.startEvents())
return tea.Batch(cmds...)
case "T": // tombol cepat tema (PRD §5.4): auto → dark → light → terminal
cur := theme.Current()
next := theme.CycleMode(cur.Mode)
theme.Set(next, cur.AccentName)
m.toast.Show(components.LevelInfo,
i18n.Tf("toast.themeMode", i18n.T("theme."+string(next))),
i18n.T("theme.switchHint"))
m.sound.Play(audio.Click)
case "t": // demo toast (sampai modal asli datang)
m.toast.Show(components.LevelSuccess, i18n.T("toast.demoTitle"), i18n.T("toast.demoBody"))
m.sound.Play(audio.NotifySuccess)
case "tab":
if m.view == viewList {
if m.focus == focusSidebar {
m.focus = focusContent
} else {
m.focus = focusSidebar
}
m.sound.Play(audio.Click)
}
case "h", "left", "[":
if m.view == viewList && m.section == sectionContainers {
return m.cycleTab(-1)
}
case "l", "right", "]":
if m.view == viewList && m.section == sectionContainers {
return m.cycleTab(1)
}
case "G": // ekor log (live) / dasar dokumen (config & env)
if m.view == viewList && m.focus == focusContent {
switch m.tab {
case tabConfig, tabEnv:
m.detailScroll = m.detailScrollMax()
default:
m.logOffset = 0
}
m.sound.Play(audio.Click)
}
case "S": // toggle timestamp log
m.showTimestamps = !m.showTimestamps
label := i18n.T("state.off")
if m.showTimestamps {
label = i18n.T("state.on")
}
m.toast.Show(components.LevelInfo, i18n.Tf("toast.timestamps", label), i18n.T("theme.switchHint"))
m.sound.Play(audio.Click)
return m.restartData()
case "j", "down":
if m.view == viewList && m.focus == focusContent {
switch m.tab {
case tabConfig, tabEnv:
if m.detailScroll < m.detailScrollMax() {
m.detailScroll++
}
default: // tab Log
if m.logOffset > 0 {
m.logOffset--
}
}
m.sound.Play(audio.Navigate)
break
}
if c := m.cur(); c < m.sectionLen()-1 {
m.setCur(c + 1)
}
m.sound.Play(audio.Navigate)
return m.restartData()
case "k", "up":
if m.view == viewList && m.focus == focusContent {
switch m.tab {
case tabConfig, tabEnv:
if m.detailScroll > 0 {
m.detailScroll--
}
default: // tab Log
if m.logOffset < len(m.logs)-1 {
m.logOffset++
}
}
m.sound.Play(audio.Navigate)
break
}
if c := m.cur(); c > 0 {
m.setCur(c - 1)
}
m.sound.Play(audio.Navigate)
return m.restartData()
}
return nil
}