feat(ui): bagian Image/Volume/Jaringan di workspace (F-003) + fix closure applyList + emulator ESC[J penuh
CI / test (push) Successful in 12m9s

This commit is contained in:
oxpoe committed 2026-10-15 14:06:51 +07:00
1 parent 8b2a86877e
commit a28b83513d
8 files changed
+487 -73

No files matched your search

+281 -60
View File
@@ -24,16 +24,38 @@ type viewKind int
const (
viewHome viewKind = iota
viewContainers
viewList
)
// containersMsg — hasil muat daftar kontainer (async via tea.Cmd).
// 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
}
// infoMsg — ringkasan daemon docker.
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
@@ -51,18 +73,24 @@ type Model struct {
fps int
lastFPSAt time.Time
view viewKind
view viewKind
section section
hostname string
sys sysinfo.Stats
cl *docker.Client
engine docker.Info
engineErr error
cl *docker.Client
engine docker.Info
containers []docker.Container
cursor int
loadErr error
loading bool
images []docker.Image
volumes []docker.Volume
networks []docker.Network
cursors [sectionCount]int
engineErr error
loadErr error
loading bool
}
func New() Model {
@@ -87,6 +115,7 @@ func New() Model {
animating: cfg.UI.AnimationEnabled,
toast: components.NewToast(),
view: viewHome,
section: sectionContainers,
hostname: host,
sys: sysinfo.Read(),
cl: cl,
@@ -109,11 +138,21 @@ func (m Model) Init() tea.Cmd {
cmds = append(cmds, animation.Next(m.tickID))
}
if m.cl != nil {
cmds = append(cmds, loadContainers(m.cl), loadInfo(m.cl))
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)
@@ -123,6 +162,33 @@ func loadContainers(cl *docker.Client) tea.Cmd {
}
}
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)
@@ -153,20 +219,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, animation.Next(m.tickID)
case containersMsg:
m.loading = false
if msg.err != nil {
m.loadErr = msg.err
m.toast.Show(components.LevelError, i18n.T("toast.dockerError"), msg.err.Error())
m.sound.Play(audio.NotifyError)
} else {
m.loadErr = nil
m.containers = msg.items
if m.cursor >= len(m.containers) {
m.cursor = max(0, len(m.containers)-1)
}
return m, loadInfo(m.cl) // sinkronkan hitungan di dashboard
}
return m, nil
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 {
@@ -201,7 +263,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.view = viewHome
m.sound.Play(audio.Click)
case "1", "enter":
m.view = viewContainers
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
@@ -209,13 +280,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.tickID = animation.NewID()
return m, animation.Next(m.tickID)
}
case "r": // muat ulang data docker
case "r": // muat ulang semua data docker
if m.cl == nil {
break
}
m.loading = true
m.sound.Play(audio.Click)
return m, tea.Batch(loadContainers(m.cl), loadInfo(m.cl))
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)
@@ -228,13 +299,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.toast.Show(components.LevelSuccess, i18n.T("toast.demoTitle"), i18n.T("toast.demoBody"))
m.sound.Play(audio.NotifySuccess)
case "j", "down":
if len(m.containers) > 0 && m.cursor < len(m.containers)-1 {
m.cursor++
if c := m.cur(); c < m.sectionLen()-1 {
m.setCur(c + 1)
}
m.sound.Play(audio.Navigate)
case "k", "up":
if m.cursor > 0 {
m.cursor--
if c := m.cur(); c > 0 {
m.setCur(c - 1)
}
m.sound.Play(audio.Navigate)
}
@@ -245,12 +316,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.toast.Active() {
m.toast = components.NewToast() // klik = tutup toast
}
// hit-test sidebar (hanya di view kontainer):
// hit-test sidebar (hanya di view list):
// baris item pertama = headerRows + 1 (border atas panel)
if m.view == viewContainers && msg.X < m.leftWidth() && len(m.containers) > 0 {
if m.view == viewList && msg.X < m.leftWidth() && m.sectionLen() > 0 {
idx := msg.Y - (m.headerRows() + 1) + m.scrollStart()
if idx >= 0 && idx < len(m.containers) {
m.cursor = idx
if idx >= 0 && idx < m.sectionLen() {
m.setCur(idx)
}
}
}
@@ -258,6 +329,90 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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 {
@@ -294,10 +449,10 @@ func (m Model) leftWidth() int {
// 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.cursor < rows {
if rows <= 0 || m.cur() < rows {
return 0
}
return m.cursor - rows + 1
return m.cur() - rows + 1
}
func stateStyle(state string) (glyph string, c theme.Color) {
@@ -316,7 +471,7 @@ func stateStyle(state string) (glyph string, c theme.Color) {
}
}
// --- styling helpers (semua via token tema — F-asi §5.4) ---
// --- styling helpers (semua via token tema — PRD §5.4) ---
func dim(s string) string {
return theme.Style(theme.Current().Subtext).Render(s)
@@ -436,7 +591,8 @@ func (m Model) footerLine(hints string) string {
func (m Model) sidebarView() string {
w, h := m.leftWidth(), m.bodyHeight()
title, meta := i18n.T("section.containers"), fmt.Sprintf("(%d)", len(m.containers))
title := m.sectionName()
meta := fmt.Sprintf("(%d)", m.sectionLen())
if m.loading {
meta = i18n.T("state.loading")
}
@@ -445,39 +601,104 @@ func (m Model) sidebarView() string {
switch {
case m.loadErr != nil:
lines = append(lines, red(i18n.T("error.loadFailed")), dim(i18n.T("hint.retry")))
case m.loading:
case m.loading && m.sectionLen() == 0:
lines = append(lines, dim(i18n.T("state.loading")))
case len(m.containers) == 0:
lines = append(lines, dim(i18n.T("state.noContainers")))
case m.sectionLen() == 0:
lines = append(lines, dim(i18n.Tf("hint.emptyItems", title)))
default:
rows := h - 2
start := m.scrollStart()
innerW := w - 4
for i := start; i < len(m.containers) && i-start < rows; i++ {
c := m.containers[i]
glyph, color := stateStyle(c.State)
mark := " "
if i == m.cursor {
mark = styled(theme.Current().Accent, "▶ ")
}
lines = append(lines, mark+styled(color, glyph)+" "+text(truncate(c.Name, innerW-4)))
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()
title, meta := i18n.T("section.detail"), ""
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 {
case m.loadErr != nil:
lines = append(lines, red(i18n.T("error.loadContainers")), "", dim(truncate(m.loadErr.Error(), w-6)))
case len(m.containers) == 0:
lines = append(lines, dim(i18n.T("hint.noContainers")))
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[min(m.cursor, len(m.containers)-1)]
c := m.containers[idx]
glyph, color := stateStyle(c.State)
title, meta = c.Name, c.State
lines = append(lines,