feat(stack): converter docker run→compose (port REA modul 1) — 19 golden vektor + CLI tia-love convert
CI / test (push) Successful in 24s
CI / test (push) Successful in 24s
This commit is contained in:
1 parent
f555f27467
commit
b84afdb494
11 files changed
+1519
-159
No files matched your search
+288
-150
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user