package app import ( "context" "fmt" "os" "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/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 ) // section — bagian resource di workspace (PRD F-001: 1-4 jump to section). type section int const ( sectionContainers section = iota sectionImages sectionVolumes sectionNetworks sectionCount = 4 ) // --- 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 hostname string sys sysinfo.Stats cl *docker.Client engine docker.Info 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, err := docker.New() 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: sectionContainers, hostname: host, sys: sysinfo.Read(), cl: cl, loading: true, } 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 } return m } func (m Model) Init() tea.Cmd { cmds := []tea.Cmd{tea.RequestBackgroundColor} // untuk mode tema "auto" if m.animating { cmds = append(cmds, animation.Next(m.tickID)) } if m.cl != nil { cmds = append(cmds, loadAll(m.cl)...) } return tea.Batch(cmds...) } 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: return m.applyList(msg.err, func(mm *Model) { mm.containers = msg.items }) 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 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: switch msg.String() { case "q", "ctrl+c": m.sound.Play(audio.ModalClose) return m, tea.Quit case "esc": if m.view == viewHome { m.sound.Play(audio.ModalClose) return m, tea.Quit } m.view = viewHome m.sound.Play(audio.Click) case "0": m.view = viewHome m.sound.Play(audio.Click) case "1", "enter": m.jumpSection(sectionContainers) m.sound.Play(audio.Click) case "2": m.jumpSection(sectionImages) m.sound.Play(audio.Click) case "3": m.jumpSection(sectionVolumes) m.sound.Play(audio.Click) case "4": m.jumpSection(sectionNetworks) m.sound.Play(audio.Click) 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 if m.cl == nil { break } m.loading = true m.sound.Play(audio.Click) return m, tea.Batch(loadAll(m.cl)...) 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 "j", "down": if c := m.cur(); c < m.sectionLen()-1 { m.setCur(c + 1) } m.sound.Play(audio.Navigate) case "k", "up": if c := m.cur(); c > 0 { m.setCur(c - 1) } m.sound.Play(audio.Navigate) } case tea.MouseClickMsg: if msg.Button == tea.MouseLeft { m.sound.Play(audio.Click) if m.toast.Active() { m.toast = components.NewToast() // klik = tutup toast } // hit-test sidebar (hanya di view list): // baris item pertama = headerRows + 1 (border atas panel) if m.view == viewList && msg.X < m.leftWidth() && m.sectionLen() > 0 { idx := msg.Y - (m.headerRows() + 1) + m.scrollStart() if idx >= 0 && idx < m.sectionLen() { m.setCur(idx) } } } } 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 } // --- data helpers --- func (m Model) lenOf(s section) int { switch s { 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 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() 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")) } 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) footerLine(hints string) string { anim := "—" if m.animating { anim = fmt.Sprintf("%d", m.fps) } return dim(truncate(i18n.Tf("footer.status", hints, anim, m.sound.Status()), m.width)) } 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, false) } func (m Model) sectionLine(i, innerW int) string { mark := " " if i == m.cur() { mark = styled(theme.Current().Accent, "▶ ") } th := theme.Current() switch m.section { case sectionImages: im := m.images[i] glyph, color := "○", th.Overlay if m.imageInUse(im.ID) { glyph, color = "●", th.Success } return mark + styled(color, glyph) + " " + text(truncate(imageDisplayName(im), innerW-10)) + dim(" "+humanBytes(im.Size)) case sectionVolumes: v := m.volumes[i] return mark + text(truncate(v.Name, innerW-6)) + dim(" "+v.Driver) case sectionNetworks: n := m.networks[i] return mark + text(truncate(n.Name, innerW-6)) + dim(" "+n.Driver) default: c := m.containers[i] glyph, color := stateStyle(c.State) return mark + styled(color, glyph) + " " + text(truncate(c.Name, innerW-4)) } } func (m Model) contentView() string { w, h := m.width-m.leftWidth(), m.bodyHeight() 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, false) } if m.sectionLen() == 0 { return panel(m.sectionName(), "", []string{ dim(i18n.Tf("hint.emptyItems", m.sectionName())), }, w, h, false) } idx := min(m.cur(), m.sectionLen()-1) 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), ) default: c := m.containers[idx] glyph, color := stateStyle(c.State) title, meta = c.Name, c.State lines = append(lines, field(i18n.T("field.id"))+dim(c.ID), field(i18n.T("field.image"))+dim(truncate(c.Image, w-20)), field(i18n.T("field.state"))+styled(color, glyph+" "+i18n.TOr("state."+c.State, c.State)), field(i18n.T("field.status"))+dim(truncate(c.Status, w-20)), "", dim(i18n.T("hint.tabsComing")), ) } return panel(title, meta, lines, w, h, false) }