feat(ui): bagian Image/Volume/Jaringan di workspace (F-003) + fix closure applyList + emulator ESC[J penuh
CI / test (push) Successful in 12m9s
CI / test (push) Successful in 12m9s
This commit is contained in:
1 parent
8b2a86877e
commit
a28b83513d
8 files changed
+487
-73
No files matched your search
+281
-60
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user