CI / test (push) Successful in 26s
Temuan CI (runner bersih, nama kontainer 112 char): - panel(): judul/meta panjang melebihi lebar panel dan mendorong kolom kanan (Aksi Tab) keluar layar — sekarang dipangkas + jaring pengaman ansiTruncate; unit test TestPanelTitleFits menutup kasus ini. - footer: aturan 'buang status dulu' membuat 'audio:'/animasi hilang di runner tanpa audio device — dikembalikan ke perilaku truncate semula. - TestTabActionColumn menunggu panel detail tampil dulu (hapus balapan muat). - TestToolbarSearch memilih prefix yang benar-benar unik + 'j' dulu.
1900 lines
49 KiB
Go
1900 lines
49 KiB
Go
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
|
||
|
||
searching bool // mode cari daftar (pill toolbar): ketik → lompat ke item
|
||
query string // isi pencarian; kosong = tidak menyaring
|
||
|
||
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
|
||
|
||
modal *converterModal
|
||
|
||
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 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
|
||
}
|
||
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:
|
||
if m.modal != nil {
|
||
return m, m.modalKeyMsg(msg) // modal menangkap semua tombol
|
||
}
|
||
if m.searching {
|
||
return m, m.searchKeyMsg(msg) // mode cari menangkap semua teks
|
||
}
|
||
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
|
||
}
|
||
// Elemen UI eksplisit dulu (F-022): hint footer, toolbar, tab bar,
|
||
// kolom aksi tab, panel beranda.
|
||
if cmd, handled := m.mouseUI(msg.X, msg.Y); handled {
|
||
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.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
|
||
}
|
||
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
|
||
}
|
||
|
||
// toolRows — baris toolbar workspace (1 di view daftar, 0 di beranda).
|
||
func (m Model) toolRows() int {
|
||
if m.view == viewList {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func (m Model) headerRows() int { return 1 + m.toolRows() + m.toastRows() }
|
||
|
||
func (m Model) bodyHeight() int {
|
||
h := m.height - 3 - m.toastRows() - m.toolRows() // header + toolbar + footer + margin
|
||
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)))
|
||
|
||
// Judul/meta dipangkas agar baris border TIDAK pernah melebihi lebar panel
|
||
// (nama kontainer panjang dulu mendorong kolom kanan keluar layar).
|
||
avail := w - 6
|
||
if meta != "" {
|
||
avail = w - 8
|
||
}
|
||
metaW := 0
|
||
if meta != "" && avail > 4 {
|
||
metaW = min(len([]rune(meta)), max(avail/3, 2))
|
||
}
|
||
left := border.Render("╭─ ") + text(truncate(title, max(avail-metaW, 1)))
|
||
right := border.Render("─╮")
|
||
if meta != "" {
|
||
right = " " + dim(truncate(meta, metaW)) + border.Render(" ─╮")
|
||
}
|
||
gap := w - lipgloss.Width(left) - lipgloss.Width(right)
|
||
if gap < 1 {
|
||
gap = 1
|
||
}
|
||
|
||
var b strings.Builder
|
||
b.WriteString(ansiTruncate(left+border.Render(strings.Repeat("─", gap))+right, w))
|
||
|
||
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)
|
||
if m.view == viewList {
|
||
parts = append(parts, m.toolbarLine())
|
||
}
|
||
parts = append(parts, body, footer)
|
||
|
||
content := m.overlayModal(strings.Join(parts, "\n"))
|
||
|
||
v := tea.NewView(content)
|
||
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.
|
||
// handled=true berarti klik dikonsumsi (walau tanpa aksi) supaya tidak jatuh
|
||
// ke pemilihan baris generik di bawahnya.
|
||
func (m *Model) mouseUI(x, y int) (tea.Cmd, bool) {
|
||
// 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), true
|
||
}
|
||
return nil, false
|
||
}
|
||
// 2) Toolbar workspace (baris di bawah header): pill cari + tombol stack.
|
||
if m.view == viewList && y == m.headerRows()-1 {
|
||
return m.toolbarKeyAt(x), true
|
||
}
|
||
// 3) 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(), true
|
||
}
|
||
// 3b) Kolom aksi tab (paling kanan): baris = item aksi tab aktif.
|
||
if _, wAct := m.detailWidths(); wAct > 0 && x >= m.width-wAct {
|
||
if row := y - (m.headerRows() + 1); row >= 0 {
|
||
items := m.tabActions()
|
||
if row < len(items) {
|
||
return m.handleKey(items[row][0]), true
|
||
}
|
||
}
|
||
return nil, true
|
||
}
|
||
}
|
||
// 4) 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), true
|
||
}
|
||
}
|
||
}
|
||
// 5) Panel beranda (baris kontainer, panel Docker, CTA masuk workspace).
|
||
if m.view == viewHome {
|
||
if cmd := m.homeClick(x, y); cmd != nil {
|
||
return cmd, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
// toolbarPlain — toolbar workspace tanpa gaya warna (sumber koordinat hit-test).
|
||
func (m Model) toolbarPlain() string {
|
||
search := "/ " + i18n.T("toolbar.search")
|
||
if m.searching {
|
||
search = "/ " + m.query + "▌"
|
||
}
|
||
left := "[ " + search + " ]"
|
||
right := "[ " + i18n.T("toolbar.addStack") + " ] [ " + i18n.T("toolbar.convert") + " ]"
|
||
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
|
||
if gap < 2 {
|
||
return ansiTruncate(left, m.width)
|
||
}
|
||
return left + strings.Repeat(" ", gap) + right
|
||
}
|
||
|
||
func (m Model) toolbarLine() string {
|
||
th := theme.Current()
|
||
search := "/ " + i18n.T("toolbar.search")
|
||
if m.searching {
|
||
search = "/ " + m.query + "▌"
|
||
}
|
||
left := theme.Style(th.Accent).Render("[ " + search + " ]")
|
||
right := theme.Style(th.Accent).Render(
|
||
"[ " + i18n.T("toolbar.addStack") + " ] [ " + i18n.T("toolbar.convert") + " ]")
|
||
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
|
||
if gap < 2 {
|
||
return ansiTruncate(left, m.width)
|
||
}
|
||
return left + strings.Repeat(" ", gap) + right
|
||
}
|
||
|
||
// toolbarKeyAt — klik toolbar workspace: pill Cari / tombol stack.
|
||
func (m *Model) toolbarKeyAt(x int) tea.Cmd {
|
||
label, ok := labelZoneAt(m.toolbarPlain(), x)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
switch label {
|
||
case "/ " + i18n.T("toolbar.search"):
|
||
m.searching, m.query = true, ""
|
||
m.focus = focusSidebar
|
||
m.sound.Play(audio.Click)
|
||
// shortcut: "+ stack" untuk kini = modal konverter (jalur buat stack yang
|
||
// ada); ganti saat modal add-stack (editor stack) dibangun.
|
||
case i18n.T("toolbar.addStack"), i18n.T("toolbar.convert"):
|
||
return m.handleKey("c")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// searchKeyMsg — mode cari daftar: ketik = lompat ke item yang cocok.
|
||
// Esc batal (bersihkan), Enter bertahan di pilihan, Ctrl+C keluar aplikasi.
|
||
func (m *Model) searchKeyMsg(msg tea.KeyPressMsg) tea.Cmd {
|
||
switch {
|
||
case msg.Code == 'c' && msg.Mod&tea.ModCtrl != 0:
|
||
m.searching = false
|
||
m.stopLogs()
|
||
if m.eventsCancel != nil {
|
||
m.eventsCancel()
|
||
}
|
||
m.sound.Play(audio.ModalClose)
|
||
return tea.Quit
|
||
case msg.Code == tea.KeyEsc:
|
||
m.searching, m.query = false, ""
|
||
m.sound.Play(audio.ModalClose)
|
||
case msg.Code == tea.KeyEnter:
|
||
m.searching = false
|
||
m.sound.Play(audio.Click)
|
||
case msg.Code == tea.KeyBackspace:
|
||
if r := []rune(m.query); len(r) > 0 {
|
||
m.query = string(r[:len(r)-1])
|
||
}
|
||
return m.searchJumpCmd()
|
||
default:
|
||
if msg.Text != "" {
|
||
m.query += msg.Text
|
||
return m.searchJumpCmd()
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// searchJumpCmd — kursor ke item pertama yang namanya memuat query (cari-lompat,
|
||
// bukan filter: daftar tetap utuh).
|
||
func (m *Model) searchJumpCmd() tea.Cmd {
|
||
q := strings.ToLower(strings.TrimSpace(m.query))
|
||
if q == "" || m.sectionLen() == 0 {
|
||
return nil
|
||
}
|
||
for i := 0; i < m.sectionLen(); i++ {
|
||
if strings.Contains(strings.ToLower(m.itemName(i)), q) {
|
||
if i == m.cur() {
|
||
return nil
|
||
}
|
||
m.setCur(i)
|
||
m.sound.Play(audio.Navigate)
|
||
return m.restartData()
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// itemName — nama tampil item indeks i (dasar pencarian & pencocokan).
|
||
func (m Model) itemName(i int) string {
|
||
switch m.section {
|
||
case sectionStacks:
|
||
return m.stacks[i].Name
|
||
case sectionImages:
|
||
return imageDisplayName(m.images[i])
|
||
case sectionVolumes:
|
||
return m.volumes[i].Name
|
||
case sectionNetworks:
|
||
return m.networks[i].Name
|
||
default:
|
||
return m.containers[i].Name
|
||
}
|
||
}
|
||
|
||
// detailWidths — lebar panel detail kontainer & kolom aksi tab (0 = tak tampil
|
||
// di layar sempit, agar tab bar tetap utuh).
|
||
func (m Model) detailWidths() (wDetail, wAct int) {
|
||
w := m.width - m.leftWidth()
|
||
if m.section == sectionContainers && w >= 66 {
|
||
wAct = min(max(int(float64(w)*0.22), 15), 20)
|
||
}
|
||
return w - wAct, wAct
|
||
}
|
||
|
||
// tabActions — aksi kontekstual tab aktif (kolom kanan, mock "TAB ACTION").
|
||
func (m Model) tabActions() [][2]string {
|
||
switch m.tab {
|
||
case tabLogs:
|
||
return [][2]string{{"S", i18n.T("tabaction.timestamp")}, {"G", i18n.T("tabaction.follow")}}
|
||
case tabConfig, tabEnv:
|
||
return [][2]string{{"G", i18n.T("tabaction.bottom")}}
|
||
default:
|
||
return [][2]string{{"r", i18n.T("tabaction.reload")}}
|
||
}
|
||
}
|
||
|
||
// tabActionLines — baris kolom aksi tab; tinggi WAJIB sama alokasi panel
|
||
// (hit-test per-baris bergantung padanya).
|
||
func (m Model) tabActionLines(w, rows int) []string {
|
||
items := m.tabActions()
|
||
out := make([]string, 0, rows)
|
||
for _, it := range items {
|
||
out = append(out, theme.Style(theme.Current().Accent).Render("["+it[0]+"]")+" "+
|
||
text(truncate(it[1], w)))
|
||
}
|
||
for len(out) < rows {
|
||
out = append(out, "")
|
||
}
|
||
if len(out) > rows {
|
||
out = out[:rows]
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
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 + kolom aksi tab di kanan (mock "TAB ACTION").
|
||
if m.section == sectionContainers {
|
||
c := m.containers[idx]
|
||
wDetail, wAct := m.detailWidths()
|
||
lines := []string{m.tabBarLine(wDetail - 4)}
|
||
switch m.tab {
|
||
case tabLogs:
|
||
lines = append(lines, m.logLines(h-3)...)
|
||
case tabStats:
|
||
lines = append(lines, m.statsLines(h-3, wDetail-4)...)
|
||
case tabConfig:
|
||
lines = append(lines, m.inspectLines(h-3, wDetail-4)...)
|
||
case tabEnv:
|
||
lines = append(lines, m.envLines(h-3, wDetail-4)...)
|
||
case tabTop:
|
||
lines = append(lines, m.procLines(h-3, wDetail-4)...)
|
||
default:
|
||
lines = append(lines, "", dim(i18n.T("hint.tabComing")))
|
||
}
|
||
detail := panel(c.Name, c.State, lines, wDetail, h, focused)
|
||
if wAct == 0 {
|
||
return detail
|
||
}
|
||
return lipgloss.JoinHorizontal(lipgloss.Top, detail,
|
||
panel(i18n.T("tab.action"), "", m.tabActionLines(wAct-4, h-2), wAct, 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 "c": // modal konverter docker run → compose (PRD §5.1.3)
|
||
m.modal = newConverterModal()
|
||
m.sound.Play(audio.ModalOpen)
|
||
case "/": // mode cari daftar (paritas keyboard pill "Cari" di toolbar)
|
||
if m.view == viewList {
|
||
m.searching, m.query = true, ""
|
||
m.focus = focusSidebar
|
||
m.sound.Play(audio.Click)
|
||
}
|
||
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
|
||
}
|