package app import ( "bytes" "context" "fmt" "os" "regexp" "strings" "time" 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/config" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker" "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/sysinfo" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/animation" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/theme" ) type viewKind int const ( viewHome viewKind = iota viewList ) type focusKind int const ( focusSidebar focusKind = iota focusContent ) // Tab detail di panel konten (PRD §5.2, versi awal: Log berfungsi). const ( tabLogs = iota tabStats tabConfig tabEnv tabTop tabCount ) // section — bagian resource di workspace (urutan PRD F-001: // Stacks · Containers · Images · Volumes · Networks). type section int const ( sectionStacks section = iota sectionContainers sectionImages sectionVolumes sectionNetworks sectionCount = 5 ) // statsHistory — jumlah sampel riwayat (60 ≈ 1 menit @1 dtk, PRD F-005). const statsHistory = 60 // --- messages (async via tea.Cmd) --- type containersMsg struct { items []docker.Container err error } type imagesMsg struct { items []docker.Image err error } type volumesMsg struct { items []docker.Volume err error } type networksMsg struct { items []docker.Network err error } type infoMsg struct { info docker.Info err error } type Model struct { width, height int sound *audio.Engine toast components.Toast tickID int64 animating bool phase float64 frames int fps int lastFPSAt time.Time view viewKind section section focus focusKind tab int tailLines int showTimestamps bool logs []string logCh chan string logCancel context.CancelFunc logID int64 logOffset int logEOF bool stats []docker.Stats statsID int64 inspectID int64 inspectJSON []byte inspectErr error envID int64 env []string envErr error procsID int64 procsTitle []string procsRows [][]string procsErr error detailScroll int eventsCancel context.CancelFunc eventsCh <-chan docker.Event eventsID int64 activity []docker.Event refreshGen int64 selID string initCmds []tea.Cmd hostname string sys sysinfo.Stats cl *docker.Client compose *docker.ComposeClient engine docker.Info engineName string stacksDir string stacks []stack.Stack stacksID int64 stacksErr error stackBusy string // nama stack yang sedang menjalankan aksi stackBusyAct string // kode aksi berjalan (u/s/R/p/D) stackConfirm string // nama stack menunggu konfirmasi turunkan (D) stackActID int64 containers []docker.Container images []docker.Image volumes []docker.Volume networks []docker.Network cursors [sectionCount]int engineErr error loadErr error loading bool } func New() Model { cfg, cfgErr := config.Load() // shortcut: TIA_LANG = override khusus dev/tes; sumber resmi bahasa // tetap config (ui.language) — dihapus saat layar Pengaturan jadi. if l := os.Getenv("TIA_LANG"); l != "" { cfg.UI.Language = l } i18n.SetLang(cfg.UI.Language) theme.Init(theme.Mode(cfg.UI.ThemeMode), cfg.UI.ThemeAccent) cl, engineInfo, err := docker.Detect(cfg.Docker.Host) host, _ := os.Hostname() m := Model{ sound: audio.New(audio.Options{ Enabled: cfg.UI.SoundEnabled, Volume: cfg.UI.SoundVolume, MuteOnSSH: cfg.UI.SoundMuteOnSSH, }), tickID: animation.NewID(), animating: cfg.UI.AnimationEnabled, toast: components.NewToast(), view: viewHome, section: sectionStacks, focus: focusSidebar, tab: tabLogs, hostname: host, sys: sysinfo.Read(), cl: cl, engineName: engineInfo.Name, compose: docker.NewCompose(cfg.Docker.ComposeCommand, engineInfo.Name), stacksDir: config.ExpandPath(cfg.StacksDir), loading: true, tailLines: cfg.UI.LogTailLines, showTimestamps: cfg.UI.ShowTimestamps, } if cfgErr != nil { m.toast.Show(components.LevelWarning, i18n.T("toast.configError"), cfgErr.Error()) } if err != nil { m.loadErr = err m.engineErr = err m.loading = false } // Perintah awal dikumpulkan di sini: Init() adalah value receiver sehingga // tidak bisa memutasi model yang dipegang program (id stream, ctx cancel). m.initCmds = []tea.Cmd{tea.RequestBackgroundColor} // untuk mode tema "auto" if m.animating { m.initCmds = append(m.initCmds, animation.Next(m.tickID)) } if m.cl != nil { m.initCmds = append(m.initCmds, loadAll(m.cl)...) if cmd := m.startEvents(); cmd != nil { m.initCmds = append(m.initCmds, cmd) } } if m.compose != nil { m.stacksID++ m.initCmds = append(m.initCmds, fetchStacks(m.stacksID, m.compose, m.stacksDir)) } return m } func (m Model) Init() tea.Cmd { return tea.Batch(m.initCmds...) } func loadAll(cl *docker.Client) []tea.Cmd { return []tea.Cmd{ loadContainers(cl), loadImages(cl), loadVolumes(cl), loadNetworks(cl), loadInfo(cl), } } func loadContainers(cl *docker.Client) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() items, err := cl.ListContainers(ctx) return containersMsg{items: items, err: err} } } func loadImages(cl *docker.Client) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() items, err := cl.ListImages(ctx) return imagesMsg{items: items, err: err} } } func loadVolumes(cl *docker.Client) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() items, err := cl.ListVolumes(ctx) return volumesMsg{items: items, err: err} } } func loadNetworks(cl *docker.Client) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() items, err := cl.ListNetworks(ctx) return networksMsg{items: items, err: err} } } func loadInfo(cl *docker.Client) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() info, err := cl.Info(ctx) return infoMsg{info: info, err: err} } } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case animation.TickMsg: if msg.ID != m.tickID || !m.animating { return m, nil // tick basi / reduced-motion } m.phase += 0.9 m.toast.Update(animation.FrameInterval) m.frames++ if m.lastFPSAt.IsZero() { m.lastFPSAt = msg.Time } if d := msg.Time.Sub(m.lastFPSAt); d >= time.Second { m.fps = int(float64(m.frames) / d.Seconds()) m.frames = 0 m.lastFPSAt = msg.Time m.sys = sysinfo.Read() // metrik host: refresh 1×/detik } return m, animation.Next(m.tickID) case containersMsg: mm, cmd := m.applyList(msg.err, func(mm *Model) { mm.containers = msg.items }) mm2, ok := mm.(Model) if !ok { return mm, cmd } // Auto-refresh dari events: restart stream detail HANYA bila kontainer // terpilih berubah — kalau tidak, log yang mengalir jangan diputus. newSel := "" if mm2.section == sectionContainers && mm2.sectionLen() > 0 { newSel = mm2.containers[min(mm2.cur(), mm2.sectionLen()-1)].ID } if newSel != m.selID { return mm2, tea.Batch(cmd, mm2.restartData()) } return mm2, cmd case imagesMsg: return m.applyList(msg.err, func(mm *Model) { mm.images = msg.items }) case volumesMsg: return m.applyList(msg.err, func(mm *Model) { mm.volumes = msg.items }) case networksMsg: return m.applyList(msg.err, func(mm *Model) { mm.networks = msg.items }) case infoMsg: if msg.err != nil { m.engineErr = msg.err } else { m.engineErr = nil m.engine = msg.info } return m, nil case logBatchMsg: if msg.id != m.logID { return m, nil // stream basi (seleksi sudah pindah) } if len(msg.lines) > 0 { m.logs = append(m.logs, msg.lines...) if over := len(m.logs) - m.tailLines; over > 0 { m.logs = m.logs[over:] } } if msg.eof { m.logEOF = true return m, nil } if m.logCh == nil { return m, nil } return m, waitLogBatch(m.logID, m.logCh) case logTickMsg: if msg.id != m.logID || m.logCh == nil { return m, nil } return m, waitLogBatch(m.logID, m.logCh) case statsMsg: if msg.id != m.statsID { return m, nil // stream basi } if msg.err == nil { m.stats = append(m.stats, msg.st) if over := len(m.stats) - statsHistory; over > 0 { m.stats = m.stats[over:] } } return m, statsNext(m.statsID) case statsTickMsg: if msg.id != m.statsID || m.cl == nil || m.sectionLen() == 0 { return m, nil } c := m.containers[min(m.cur(), m.sectionLen()-1)] return m, fetchStats(m.statsID, m.cl, c.ID) case inspectMsg: if msg.id != m.inspectID { return m, nil } m.inspectErr = msg.err m.inspectJSON = msg.data m.detailScroll = 0 return m, nil case envMsg: if msg.id != m.envID { return m, nil } m.envErr = msg.err m.env = msg.env m.detailScroll = 0 return m, nil case procsMsg: if msg.id != m.procsID { return m, nil } m.procsErr = msg.err m.procsTitle, m.procsRows = msg.titles, msg.rows return m, nil case stacksMsg: if msg.id != m.stacksID { return m, nil } m.stacksErr = msg.err if msg.err == nil { m.stacks = msg.items } if n := len(m.stacks); m.cursors[sectionStacks] >= n { m.cursors[sectionStacks] = max(0, n-1) } return m, nil case stackActionMsg: if msg.id != m.stackActID { return m, nil } m.stackBusy = "" // Aksi stack memengaruhi kontainer & status → segarkan keduanya. cmds := []tea.Cmd{} if m.compose != nil { m.stacksID++ cmds = append(cmds, fetchStacks(m.stacksID, m.compose, m.stacksDir)) } if m.cl != nil { cmds = append(cmds, loadAll(m.cl)...) } if msg.err != nil { m.toast.Show(components.LevelError, i18n.Tf("toast.stackFail", msg.name), lastLine(msg.out+" "+msg.err.Error())) m.sound.Play(audio.NotifyError) } else { m.toast.Show(components.LevelSuccess, i18n.Tf("toast.stackOk", msg.name, msg.label), lastLine(msg.out)) m.sound.Play(audio.NotifySuccess) } return m, tea.Batch(cmds...) case eventMsg: if msg.id != m.eventsID { return m, nil } m.activity = append(m.activity, msg.ev) if over := len(m.activity) - activityMax; over > 0 { m.activity = m.activity[over:] } m.refreshGen++ // debounce: segarkan daftar setelah burst event reda return m, tea.Batch(waitEvent(msg.id, m.eventsCh), refreshAfter(m.refreshGen)) case eventsClosedMsg: if msg.id != m.eventsID { return m, nil } m.eventsCh = nil return m, eventsRetry(m.eventsID) // daemon putus → sambung lagi nanti case eventsRetryMsg: if msg.id != m.eventsID { return m, nil } return m, m.startEvents() case refreshTickMsg: if msg.gen != m.refreshGen || m.cl == nil { return m, nil // event lebih baru menunggu tick berikutnya } cmds := loadAll(m.cl) if m.compose != nil { m.stacksID++ cmds = append(cmds, fetchStacks(m.stacksID, m.compose, m.stacksDir)) } return m, tea.Batch(cmds...) case tea.BackgroundColorMsg: // Mode tema "auto": Mocha vs Latte sesuai background terminal. theme.SetDetectedDark(msg.IsDark()) return m, nil case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height case tea.KeyPressMsg: return m, m.handleKey(msg.String()) case tea.MouseClickMsg: if msg.Button == tea.MouseLeft { 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 } else { m.focus = focusSidebar if m.sectionLen() > 0 { // baris item pertama = headerRows + 1 (border atas panel) idx := msg.Y - (m.headerRows() + 1) + m.scrollStart() if idx >= 0 && idx < m.sectionLen() { m.setCur(idx) return m, m.restartData() } } } } } case tea.MouseWheelMsg: if m.view != viewList || m.focus != focusContent { return m, nil } up := msg.Button == tea.MouseWheelUp switch m.tab { case tabConfig, tabEnv: if up && m.detailScroll < m.detailScrollMax() { m.detailScroll++ } else if !up && m.detailScroll > 0 { m.detailScroll-- } default: // tab Log if up && m.logOffset < len(m.logs)-1 { m.logOffset++ } else if !up && m.logOffset > 0 { m.logOffset-- } } } return m, nil } // applyList — jalur bersama semua hasil muat daftar. // Dipanggil dengan pointer supaya mutasi set() mengenai salinan receiver // yang memang dikembalikan (bug closure-by-value pernah terjadi di sini). func (m Model) applyList(err error, set func(*Model)) (tea.Model, tea.Cmd) { m.loading = false if err != nil { m.loadErr = err m.toast.Show(components.LevelError, i18n.T("toast.dockerError"), err.Error()) m.sound.Play(audio.NotifyError) return m, nil } m.loadErr = nil set(&m) for i := range m.cursors { if n := m.lenOf(section(i)); m.cursors[i] >= n { m.cursors[i] = max(0, n-1) } } return m, nil } func (m *Model) jumpSection(s section) { m.section = s m.view = viewList m.focus = focusSidebar } // stopLogs — matikan stream aktif tanpa memulai yang baru. func (m *Model) stopLogs() { if m.logCancel != nil { m.logCancel() m.logCancel = nil } m.logCh = nil m.logs = nil m.logOffset = 0 m.logEOF = false } // restartLogs — batalkan stream lama lalu buka stream baru bila konteksnya // cocok (view list · section Kontainer · tab Log). Pola lazydocker // (REA modul 5): satu stream aktif, dibatalkan saat seleksi berpindah. func (m *Model) restartLogs() tea.Cmd { m.stopLogs() if m.view != viewList || m.section != sectionContainers || m.tab != tabLogs || m.cl == nil || m.sectionLen() == 0 { return nil } c := m.containers[min(m.cur(), m.sectionLen()-1)] m.logID++ id := m.logID ch := make(chan string, 256) m.logCh = ch ctx, cancel := context.WithCancel(context.Background()) m.logCancel = cancel cl, tail, ts := m.cl, m.tailLines, m.showTimestamps go streamContainerLogs(ctx, cl, c.ID, tail, ts, ch) return waitLogBatch(id, ch) } func (m *Model) cycleTab(delta int) tea.Cmd { if m.view != viewList || m.section != sectionContainers { return nil } m.tab = (m.tab + delta + tabCount) % tabCount m.focus = focusContent m.sound.Play(audio.Click) return m.restartData() } // restartStats — bersihkan riwayat & mulai snapshot periodik (one-shot @1 dtk) // bila konteksnya cocok (tab Statistik). Tidak ada stream persisten. func (m *Model) restartStats() tea.Cmd { m.statsID++ m.stats = nil if m.view != viewList || m.section != sectionContainers || m.tab != tabStats || m.cl == nil || m.sectionLen() == 0 { return nil } c := m.containers[min(m.cur(), m.sectionLen()-1)] return fetchStats(m.statsID, m.cl, c.ID) } // restartData — restart semua sumber data detail (log + stats + inspect/env/ // proses) untuk seleksi saat ini; masing-masing menyaring sendiri konteksnya. func (m *Model) restartData() tea.Cmd { if m.section == sectionContainers && m.sectionLen() > 0 { m.selID = m.containers[min(m.cur(), m.sectionLen()-1)].ID } return tea.Batch( m.restartLogs(), m.restartStats(), m.restartInspect(), m.restartEnv(), m.restartProcs(), ) } func (m *Model) detailCmdFor(tab int, fetch func(id int64, cl *docker.Client, cid string) tea.Cmd, id *int64) tea.Cmd { *id++ if m.view != viewList || m.section != sectionContainers || m.tab != tab || m.cl == nil || m.sectionLen() == 0 { return nil } c := m.containers[min(m.cur(), m.sectionLen()-1)] return fetch(*id, m.cl, c.ID) } func (m *Model) restartInspect() tea.Cmd { m.inspectJSON, m.inspectErr = nil, nil return m.detailCmdFor(tabConfig, fetchInspect, &m.inspectID) } func (m *Model) restartEnv() tea.Cmd { m.env, m.envErr = nil, nil return m.detailCmdFor(tabEnv, fetchEnv, &m.envID) } func (m *Model) restartProcs() tea.Cmd { m.procsTitle, m.procsRows, m.procsErr = nil, nil, nil return m.detailCmdFor(tabTop, fetchProcs, &m.procsID) } // --- data helpers --- func (m Model) lenOf(s section) int { switch s { case sectionStacks: return len(m.stacks) case sectionImages: return len(m.images) case sectionVolumes: return len(m.volumes) case sectionNetworks: return len(m.networks) default: return len(m.containers) } } func (m Model) sectionLen() int { return m.lenOf(m.section) } func (m Model) cur() int { return m.cursors[m.section] } func (m *Model) setCur(v int) { m.cursors[m.section] = v } func (m Model) sectionName() string { switch m.section { case sectionStacks: return i18n.T("section.stacks") case sectionImages: return i18n.T("section.images") case sectionVolumes: return i18n.T("section.volumes") case sectionNetworks: return i18n.T("section.networks") default: return i18n.T("section.containers") } } func shortID(s string) string { s = strings.TrimPrefix(s, "sha256:") if len(s) > 12 { s = s[:12] } return s } func (m Model) imageInUse(id string) bool { for _, c := range m.containers { if shortID(c.ImageID) == id { return true } } return false } func imageDisplayName(im docker.Image) string { if len(im.Tags) > 0 { return im.Tags[0] } return im.ID } // --- layout helpers --- func (m Model) toastRows() int { if m.toast.Active() { return 5 // 4 baris toast + 1 baris jarak } return 0 } func (m Model) headerRows() int { return 1 + m.toastRows() } func (m Model) bodyHeight() int { h := m.height - 3 - m.toastRows() // header(1) + footer(1) + margin(1) if h < 5 { h = 5 } return h } func (m Model) leftWidth() int { w := int(float64(m.width) * 0.33) if w < 24 { w = 24 } if w > 46 { w = 46 } if w > m.width-24 { w = max(16, m.width-24) } return w } // scrollStart — offset window daftar agar kursor selalu terlihat. func (m Model) scrollStart() int { rows := m.bodyHeight() - 2 // konten dalam panel (tanpa border) if rows <= 0 || m.cur() < rows { return 0 } return m.cur() - rows + 1 } func stateStyle(state string) (glyph string, c theme.Color) { th := theme.Current() switch state { case "running": return "●", th.Success case "paused": return "⏸", th.Warning case "restarting", "created": return "◌", th.Info case "exited", "dead": return "○", th.Danger default: return "○", th.Overlay } } // --- styling helpers (semua via token tema — PRD §5.4) --- func dim(s string) string { return theme.Style(theme.Current().Subtext).Render(s) } func text(s string) string { return theme.Style(theme.Current().Text).Render(s) } func red(s string) string { return theme.Style(theme.Current().Danger).Render(s) } func styled(c theme.Color, s string) string { return theme.Style(c).Render(s) } func truncate(s string, w int) string { r := []rune(s) if w <= 1 || len(r) <= w { return s } return string(r[:w-1]) + "…" } // field — label kolom (padding tetap agar nilai sejajar). func field(label string) string { return fieldW(label, 7) } func fieldW(label string, w int) string { return text(fmt.Sprintf("%-*s", w, label)) } // panel — kotak gaya mirador: judul di border atas (kiri), meta (kanan), // border redup; konten otomatis dipangkas/padded selebar panel. func panel(title, meta string, lines []string, w, h int, focused bool) string { if w < 6 || h < 2 { return "" } th := theme.Current() bc := th.Overlay if focused { bc = th.Accent } border := lipgloss.NewStyle().Foreground(lipgloss.Color(string(bc))) titleS := text(title) left := border.Render("╭─ ") + titleS right := border.Render("─╮") if meta != "" { right = " " + dim(meta) + border.Render(" ─╮") } gap := w - lipgloss.Width(left) - lipgloss.Width(right) if gap < 1 { gap = 1 } var b strings.Builder b.WriteString(left) b.WriteString(border.Render(strings.Repeat("─", gap))) b.WriteString(right) content := lipgloss.NewStyle().Width(w - 4) for i := 0; i < h-2; i++ { l := "" if i < len(lines) { l = lines[i] } b.WriteString("\n") b.WriteString(border.Render("│")) b.WriteString(" ") b.WriteString(content.Render(ansiTruncate(l, w-4))) b.WriteString(" ") b.WriteString(border.Render("│")) } b.WriteString("\n") b.WriteString(border.Render("╰" + strings.Repeat("─", w-2) + "╯")) return b.String() } // --- views --- func (m Model) View() tea.View { header := theme.Style(theme.Current().Accent).Bold(true). Render("tia — Terminal Is A My Way") var body, footer string switch m.view { case viewHome: header += dim(" · " + i18n.T("nav.home")) body = m.homeBody() default: header += dim(" · " + strings.ToLower(m.sectionName())) body = lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView()) } footer = m.footerLine() parts := []string{} if t := m.toast.View(max(m.width, 20)); t != "" { parts = append(parts, t) } parts = append(parts, header, body, footer) v := tea.NewView(strings.Join(parts, "\n")) v.AltScreen = true v.MouseMode = tea.MouseModeCellMotion return v } func (m Model) footerPlain() string { hints := i18n.T("hint.footerHome") if m.view == viewList { if m.section == sectionStacks { hints = i18n.T("hint.footerStacks") } else { hints = i18n.T("hint.footerContainers") } } anim := "—" if m.animating { anim = fmt.Sprintf("%d", m.fps) } 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) Toolbar aksi / tombol konfirmasi stack (baris konten pertama detail). if m.view == viewList && m.section == sectionStacks && m.sectionLen() > 0 && y == m.headerRows()+1 { line := "" switch { case m.stackConfirm != "": line = m.stackConfirmLine() case m.stackBusy == "": line = m.stackToolbar() } if line != "" { if key, ok := keyZonesAt(line, x-m.leftWidth()-2); ok { return m.handleKey(key) } } } // 4) 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) { return keyZonesAt(m.footerPlain(), x) } // keyZonesAt — cari segmen "[k] …" pada satu baris; posisi rune x → tombol k. // Dipakai bersama oleh hint footer, toolbar stack, dan tombol konfirmasi. func keyZonesAt(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] != '·' && runes[end] != '[' { end++ // segmen: sampai pemisah "·" ATAU bracket tombol berikutnya } 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-5" → "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 { w, h := m.leftWidth(), m.bodyHeight() title := m.sectionName() meta := fmt.Sprintf("(%d)", m.sectionLen()) if m.loading { meta = i18n.T("state.loading") } var lines []string switch { case m.loadErr != nil: lines = append(lines, red(i18n.T("error.loadFailed")), dim(i18n.T("hint.retry"))) case m.loading && m.sectionLen() == 0: lines = append(lines, dim(i18n.T("state.loading"))) case m.sectionLen() == 0: lines = append(lines, dim(i18n.Tf("hint.emptyItems", title))) default: rows := h - 2 start := m.scrollStart() for i := start; i < m.sectionLen() && i-start < rows; i++ { lines = append(lines, m.sectionLine(i, w-4)) } } return panel(title, meta, lines, w, h, m.focus == focusSidebar) } func (m Model) sectionLine(i, innerW int) string { sel := i == m.cur() th := theme.Current() var line string switch m.section { case sectionStacks: s := m.stacks[i] glyph, color := stackStyle(s.Status) line = paintRow(sel, color, glyph) + paintRow(sel, th.Text, " "+truncate(s.Name, innerW-4)) case sectionImages: im := m.images[i] glyph, color := "○", th.Overlay if m.imageInUse(im.ID) { glyph, color = "●", th.Success } line = paintRow(sel, color, glyph) + paintRow(sel, th.Text, " "+truncate(imageDisplayName(im), innerW-10)) + paintRow(sel, th.Subtext, " "+humanBytes(im.Size)) case sectionVolumes: v := m.volumes[i] line = paintRow(sel, th.Text, truncate(v.Name, innerW-6)) + paintRow(sel, th.Subtext, " "+v.Driver) case sectionNetworks: n := m.networks[i] line = paintRow(sel, th.Text, truncate(n.Name, innerW-6)) + paintRow(sel, th.Subtext, " "+n.Driver) default: c := m.containers[i] glyph, color := stateStyle(c.State) line = paintRow(sel, color, glyph) + paintRow(sel, th.Text, " "+truncate(c.Name, innerW-4)) } if sel { line = selPad(line, innerW) } return line } // paintRow — satu segmen baris daftar; baris terpilih memakai latar // token Selection sehingga posisi aktif jelas tanpa perlu panah. func paintRow(sel bool, fg theme.Color, s string) string { st := theme.Style(fg) if sel { st = st.Background(lipgloss.Color(string(theme.Current().Selection))) } return st.Render(s) } // selPad — isi sisa lebar baris terpilih dengan latar Selection penuh. func selPad(s string, w int) string { gap := w - lipgloss.Width(s) if gap <= 0 { return s } bg := lipgloss.NewStyle().Background(lipgloss.Color(string(theme.Current().Selection))) return s + bg.Render(strings.Repeat(" ", gap)) } func (m Model) contentView() string { w, h := m.width-m.leftWidth(), m.bodyHeight() focused := m.view == viewList && m.focus == focusContent if m.loadErr != nil { return panel(m.sectionName(), "", []string{ red(i18n.Tf("error.loadSection", m.sectionName())), "", dim(truncate(m.loadErr.Error(), w-6)), }, w, h, focused) } if m.sectionLen() == 0 { return panel(m.sectionName(), "", []string{ dim(i18n.Tf("hint.emptyItems", m.sectionName())), }, w, h, focused) } idx := min(m.cur(), m.sectionLen()-1) // Stack: ringkasan + toolbar aksi (klikable; aksi async + refresh). if m.section == sectionStacks { if m.stacksErr != nil { return panel(m.sectionName(), "", []string{red(truncate(m.stacksErr.Error(), w-6))}, w, h, focused) } s := m.stacks[idx] glyph, color := stackStyle(s.Status) lines := []string{} switch { case m.stackConfirm == s.Name: lines = append(lines, red(i18n.Tf("stack.confirmDown", s.Name)), m.stackConfirmLine(), "") case m.stackBusy == s.Name: lines = append(lines, dim(i18n.Tf("stack.busy", i18n.T("stack."+mapActionName(m.stackBusyAct)))), "") default: lines = append(lines, m.stackToolbar(), "") } lines = append(lines, field(i18n.T("field.dir"))+dim(truncate(s.Dir, w-12)), field(i18n.T("field.composeFile"))+dim(truncate(s.ComposeFile, w-18)), field(i18n.T("field.status"))+styled(color, glyph+" "+stackStatusText(s.Status)), "", dim(i18n.T("stack.mouseHint")), ) return panel(s.Name, "", lines, w, h, focused) } // Kontainer: tab detail (Log berfungsi; sisanya menyusul Phase 2). if m.section == sectionContainers { c := m.containers[idx] lines := []string{m.tabBarLine(w - 4)} switch m.tab { case tabLogs: lines = append(lines, m.logLines(h-3)...) case tabStats: lines = append(lines, m.statsLines(h-3, w-4)...) case tabConfig: lines = append(lines, m.inspectLines(h-3, w-4)...) case tabEnv: lines = append(lines, m.envLines(h-3, w-4)...) case tabTop: lines = append(lines, m.procLines(h-3, w-4)...) default: lines = append(lines, "", dim(i18n.T("hint.tabComing"))) } return panel(c.Name, c.State, lines, w, h, focused) } title, meta := m.sectionName(), "" var lines []string switch m.section { case sectionImages: im := m.images[idx] title, meta = imageDisplayName(im), humanBytes(im.Size) tags := strings.Join(im.Tags, ", ") if tags == "" { tags = i18n.T("state.untagged") } inUse := i18n.T("state.no") if m.imageInUse(im.ID) { inUse = i18n.T("state.yes") } lines = append(lines, field(i18n.T("field.id"))+dim(im.ID), field(i18n.T("field.tags"))+dim(truncate(tags, w-12)), field(i18n.T("field.size"))+dim(humanBytes(im.Size)), field(i18n.T("field.created"))+dim(time.Unix(im.Created, 0).Format("02 Jan 2006")), field(i18n.T("field.inUse"))+dim(inUse), ) case sectionVolumes: v := m.volumes[idx] title, meta = v.Name, v.Driver lines = append(lines, field(i18n.T("field.driver"))+dim(v.Driver), field(i18n.T("field.mountpoint"))+dim(truncate(v.Mountpoint, w-14)), ) case sectionNetworks: n := m.networks[idx] title, meta = n.Name, n.Driver lines = append(lines, field(i18n.T("field.id"))+dim(n.ID), field(i18n.T("field.driver"))+dim(n.Driver), field(i18n.T("field.scope"))+dim(n.Scope), field(i18n.T("field.subnet"))+dim(n.Subnet), ) } return panel(title, meta, lines, w, h, focused) } // tabBarLine — baris tab (aktif bergaris aksen, sisanya redup). func (m Model) tabBarLine(w int) string { tabs := []string{ i18n.T("tab.logs"), i18n.T("tab.stats"), i18n.T("tab.config"), i18n.T("tab.env"), i18n.T("tab.top"), } parts := make([]string, 0, len(tabs)) for i, t := range tabs { if i == m.tab { parts = append(parts, theme.Style(theme.Current().Accent).Bold(true).Render("["+t+"]")) } else { parts = append(parts, dim(t)) } } return ansiTruncate(strings.Join(parts, " "), w) } // logLines — potongan log yang tampil: h-3 baris, offset dari ekor (live). func (m Model) logLines(rows int) []string { if len(m.logs) == 0 { msg := i18n.T("hint.logsWaiting") if m.logEOF { msg = i18n.T("hint.logsEnd") } return []string{dim(msg)} } end := len(m.logs) - m.logOffset if end > len(m.logs) { end = len(m.logs) } if end < 0 { end = 0 } start := end - rows if start < 0 { start = 0 } out := make([]string, 0, end-start) for _, l := range m.logs[start:end] { out = append(out, colorizeLog(l)) } return out } // detailScrollMax — batas scroll dokumen (tab Config/Env) dalam baris. func (m Model) detailScrollMax() int { rows := m.bodyHeight() - 3 var total int switch m.tab { case tabConfig: if len(m.inspectJSON) > 0 { total = bytes.Count(m.inspectJSON, []byte("\n")) + 1 } case tabEnv: total = len(m.env) } if total <= rows { return 0 } return total - rows } // inspectLines — JSON inspect mentah (scroll via detailScroll). func (m Model) inspectLines(rows, w int) []string { if m.inspectErr != nil { return []string{red(truncate(m.inspectErr.Error(), w))} } if len(m.inspectJSON) == 0 { return []string{dim(i18n.T("hint.inspectWaiting"))} } all := strings.Split(string(m.inspectJSON), "\n") out := make([]string, 0, rows) for i := m.detailScroll; i < len(all) && len(out) < rows; i++ { out = append(out, all[i]) } return out } // envLines — KEY=value; kunci terang, nilai redup. func (m Model) envLines(rows, w int) []string { if m.envErr != nil { return []string{red(truncate(m.envErr.Error(), w))} } if m.env == nil { return []string{dim(i18n.T("hint.inspectWaiting"))} } out := make([]string, 0, rows) for i := m.detailScroll; i < len(m.env) && len(out) < rows; i++ { kv := m.env[i] if k, v, ok := strings.Cut(kv, "="); ok { out = append(out, text(k+"=")+dim(truncate(v, max(1, w-len([]rune(k))-1)))) } else { out = append(out, text(kv)) } } return out } // procLines — tabel proses (`docker top`): header redup, kolom dipadatkan, // kolom terakhir (perintah) mengambil sisa lebar. func (m Model) procLines(rows, w int) []string { if m.procsErr != nil { return []string{red(truncate(m.procsErr.Error(), w))} } cols := len(m.procsTitle) if cols == 0 { return []string{dim(i18n.T("hint.procsWaiting"))} } widths := make([]int, cols) for i, t := range m.procsTitle { widths[i] = len([]rune(t)) } for _, r := range m.procsRows { for i, c := range r { if i < cols && len([]rune(c)) > widths[i] { widths[i] = len([]rune(c)) } } } for i := 0; i < cols-1; i++ { if widths[i] > 14 { widths[i] = 14 } } used := 0 for i := 0; i < cols-1; i++ { used += widths[i] + 2 } widths[cols-1] = max(10, w-used) row := func(cells []string, style func(string) string) string { var b strings.Builder for i := 0; i < cols; i++ { c := "" if i < len(cells) { c = cells[i] } if r := []rune(c); len(r) > widths[i] { c = string(r[:max(1, widths[i]-1)]) + "…" } b.WriteString(style(fmt.Sprintf("%-*s", widths[i], c))) if i < cols-1 { b.WriteString(" ") } } return b.String() } out := []string{row(m.procsTitle, dim)} maxRows := rows - 1 for i, r := range m.procsRows { if i >= maxRows { out = append(out, dim(fmt.Sprintf("… +%d", len(m.procsRows)-i))) break } out = append(out, row(r, text)) } return out } // logLevelRE — token level umum di log (INFO/WARN/ERRO/…). var logLevelRE = regexp.MustCompile(`\b(TRACE|TRAC|DEBUG|DEBU|INFO|WARN|WARNING|ERRO|ERROR|FATAL|FATA)\b`) // statsLines — isi tab Statistik (PRD §5.1.1): bar CPU/RAM, riwayat CPU // 60 detik sebagai sparkline, laju NET/Disk, dan PIDs. func (m Model) statsLines(rows, w int) []string { if len(m.stats) == 0 { return []string{dim(i18n.T("hint.statsWaiting"))} } cur := m.stats[len(m.stats)-1] out := []string{ fieldW("CPU", 6) + pctBar(cur.CPUPercent, 20) + dim(fmt.Sprintf(" %5.1f%%", cur.CPUPercent)), fieldW("RAM", 6) + pctBar(cur.MemPercent, 20) + dim(fmt.Sprintf(" %s / %s", humanKBs(cur.MemUsed/1024), humanKBs(cur.MemLimit/1024))), "", dim(i18n.T("stats.history")), } vals := make([]float64, 0, len(m.stats)) for _, s := range m.stats { vals = append(vals, s.CPUPercent) } out = append(out, theme.Style(theme.Current().Accent).Render(components.Sparkline(vals, min(w, statsHistory))), "") if len(m.stats) >= 2 { prev := m.stats[len(m.stats)-2] dt := cur.At.Sub(prev.At).Seconds() if dt <= 0 { dt = 1 } rate := func(curV, prevV uint64) uint64 { if curV < prevV { // counter reset (kontainer restart) return 0 } return uint64(float64(curV-prevV) / dt) } out = append(out, text("NET ")+dim("↑ "+humanBytes(int64(rate(cur.NetRx, prev.NetRx)))+"/s ↓ "+humanBytes(int64(rate(cur.NetTx, prev.NetTx)))+"/s"), text("Disk ")+dim(i18n.T("stats.read")+" "+humanBytes(int64(rate(cur.BlkRead, prev.BlkRead)))+"/s · "+ i18n.T("stats.write")+" "+humanBytes(int64(rate(cur.BlkWrite, prev.BlkWrite)))+"/s"), ) } out = append(out, text("PIDs ")+dim(fmt.Sprintf("%d", cur.PIDs))) if len(out) > rows { out = out[:rows] } return out } // colorizeLog — warnai token level (PRD §5.4: INFO=info, WARN=warning, ERRO=danger). func colorizeLog(line string) string { th := theme.Current() return logLevelRE.ReplaceAllStringFunc(line, func(tok string) string { var c theme.Color switch { case strings.HasPrefix(tok, "INFO"): c = th.Info case strings.HasPrefix(tok, "WARN"): c = th.Warning case strings.HasPrefix(tok, "TRAC"), strings.HasPrefix(tok, "DEBU"): c = th.Subtext default: c = th.Danger } 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 { // Konfirmasi destruktif (turunkan stack) menangkap input lebih dulu. if m.stackConfirm != "" { switch key { case "y": name := m.stackConfirm m.stackConfirm = "" for i, s := range m.stacks { if s.Name == name { m.setCur(i) return m.beginStackAction("D") } } case "n", "esc": m.stackConfirm = "" m.sound.Play(audio.ModalClose) } return nil } 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(sectionStacks) m.sound.Play(audio.Click) return m.restartData() case "2": m.jumpSection(sectionContainers) m.sound.Play(audio.Click) return m.restartData() case "3": m.jumpSection(sectionImages) m.sound.Play(audio.Click) return m.restartData() case "4": m.jumpSection(sectionVolumes) m.sound.Play(audio.Click) return m.restartData() case "5": 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()) m.stacksID++ cmds = append(cmds, fetchStacks(m.stacksID, m.compose, m.stacksDir)) 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 "u", "s", "R", "p": if m.view == viewList && m.section == sectionStacks { return m.beginStackAction(key) } case "D": if m.view == viewList && m.section == sectionStacks && m.sectionLen() > 0 && m.stackBusy == "" { s := m.stacks[min(m.cur(), m.sectionLen()-1)] m.stackConfirm = s.Name m.sound.Play(audio.NotifyWarning) } 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 }