go-blockchain/tui/explorer_model.go
Claude 527284327b
feat(tui): add ExplorerModel with block list, detail, and tx views
Co-Authored-By: Charon <charon@lethean.io>
2026-02-23 00:00:05 +00:00

389 lines
9 KiB
Go

// Copyright (c) 2017-2026 Lethean (https://lt.hn)
//
// Licensed under the European Union Public Licence (EUPL) version 1.2.
// SPDX-License-Identifier: EUPL-1.2
package tui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
cli "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-blockchain/chain"
"forge.lthn.ai/core/go-blockchain/types"
)
// Compile-time check: ExplorerModel implements cli.FrameModel.
var _ cli.FrameModel = (*ExplorerModel)(nil)
type explorerView int
const (
viewBlockList explorerView = iota
viewBlockDetail
viewTxDetail
)
// blockRow is a pre-fetched summary for the block list.
type blockRow struct {
Height uint64
Hash types.Hash
TxCount int
Timestamp uint64
Difficulty uint64
}
// ExplorerModel provides block list, block detail, and tx detail views.
// It implements [cli.FrameModel] for the content region of the TUI dashboard.
type ExplorerModel struct {
chain *chain.Chain
view explorerView
cursor int
rows []blockRow
// Block detail state.
block *types.Block
blockMeta *chain.BlockMeta
txCursor int
// Tx detail state.
tx *types.Transaction
txHash types.Hash
width int
height int
}
// NewExplorerModel creates an ExplorerModel backed by the given chain.
func NewExplorerModel(c *chain.Chain) *ExplorerModel {
m := &ExplorerModel{chain: c}
m.loadBlocks()
return m
}
// Init returns nil — block list is loaded synchronously in the constructor.
func (m *ExplorerModel) Init() tea.Cmd {
return nil
}
// Update handles incoming messages. KeyMsg drives navigation, NodeStatusMsg
// triggers a block list refresh, and WindowSizeMsg stores the terminal size.
func (m *ExplorerModel) Update(msg tea.Msg) (cli.FrameModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
case NodeStatusMsg:
m.loadBlocks()
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m *ExplorerModel) handleKey(msg tea.KeyMsg) (cli.FrameModel, tea.Cmd) {
switch m.view {
case viewBlockList:
return m.handleBlockListKey(msg)
case viewBlockDetail:
return m.handleBlockDetailKey(msg)
case viewTxDetail:
return m.handleTxDetailKey(msg)
}
return m, nil
}
func (m *ExplorerModel) handleBlockListKey(msg tea.KeyMsg) (cli.FrameModel, tea.Cmd) {
switch msg.Type {
case tea.KeyUp:
if m.cursor > 0 {
m.cursor--
}
case tea.KeyDown:
if m.cursor < len(m.rows)-1 {
m.cursor++
}
case tea.KeyPgUp:
m.cursor -= pageSize(m.height)
if m.cursor < 0 {
m.cursor = 0
}
case tea.KeyPgDown:
m.cursor += pageSize(m.height)
if m.cursor >= len(m.rows) {
m.cursor = len(m.rows) - 1
}
if m.cursor < 0 {
m.cursor = 0
}
case tea.KeyHome:
m.cursor = 0
case tea.KeyEnter:
if len(m.rows) > 0 && m.cursor < len(m.rows) {
row := m.rows[m.cursor]
blk, meta, err := m.chain.GetBlockByHeight(row.Height)
if err == nil {
m.block = blk
m.blockMeta = meta
m.txCursor = 0
m.view = viewBlockDetail
return m, m.viewChangedCmd()
}
}
}
return m, nil
}
func (m *ExplorerModel) handleBlockDetailKey(msg tea.KeyMsg) (cli.FrameModel, tea.Cmd) {
switch msg.Type {
case tea.KeyUp:
if m.txCursor > 0 {
m.txCursor--
}
case tea.KeyDown:
if m.block != nil && m.txCursor < len(m.block.TxHashes)-1 {
m.txCursor++
}
case tea.KeyEnter:
if m.block != nil && len(m.block.TxHashes) > 0 && m.txCursor < len(m.block.TxHashes) {
txHash := m.block.TxHashes[m.txCursor]
tx, _, err := m.chain.GetTransaction(txHash)
if err == nil {
m.tx = tx
m.txHash = txHash
m.view = viewTxDetail
return m, m.viewChangedCmd()
}
}
case tea.KeyEsc:
m.view = viewBlockList
return m, m.viewChangedCmd()
}
return m, nil
}
func (m *ExplorerModel) handleTxDetailKey(msg tea.KeyMsg) (cli.FrameModel, tea.Cmd) {
switch msg.Type {
case tea.KeyEsc:
m.view = viewBlockDetail
return m, m.viewChangedCmd()
}
return m, nil
}
// viewChangedCmd returns a command that emits a ViewChangedMsg with hints
// appropriate for the current view.
func (m *ExplorerModel) viewChangedCmd() tea.Cmd {
var hints []string
switch m.view {
case viewBlockList:
hints = []string{"↑/↓ select", "enter view", "q quit"}
case viewBlockDetail:
hints = []string{"↑/↓ select tx", "enter view tx", "esc back", "q quit"}
case viewTxDetail:
hints = []string{"esc back", "q quit"}
}
return func() tea.Msg { return ViewChangedMsg{Hints: hints} }
}
// View renders the current view, delegating to the appropriate sub-view.
func (m *ExplorerModel) View(width, height int) string {
m.width = width
m.height = height
switch m.view {
case viewBlockList:
return m.viewBlockList()
case viewBlockDetail:
return m.viewBlockDetail()
case viewTxDetail:
return m.viewTxDetail()
}
return ""
}
func (m *ExplorerModel) viewBlockList() string {
if len(m.rows) == 0 {
return " no blocks \u2014 chain is empty"
}
var b strings.Builder
// Header row.
header := fmt.Sprintf(" %-8s %-18s %5s %12s %12s",
"Height", "Hash", "Txs", "Difficulty", "Age")
b.WriteString(header)
b.WriteByte('\n')
// Visible window centred on cursor.
visibleRows := m.height - 2 // header + bottom margin
if visibleRows < 1 {
visibleRows = 1
}
start := m.cursor - visibleRows/2
if start < 0 {
start = 0
}
end := start + visibleRows
if end > len(m.rows) {
end = len(m.rows)
start = end - visibleRows
if start < 0 {
start = 0
}
}
for i := start; i < end; i++ {
row := m.rows[i]
prefix := " "
if i == m.cursor {
prefix = "> "
}
hashShort := fmt.Sprintf("%x", row.Hash[:4]) + "..."
age := formatAge(time.Unix(int64(row.Timestamp), 0))
line := fmt.Sprintf("%s%-8d %-18s %5d %12s %12s",
prefix, row.Height, hashShort, row.TxCount,
formatDifficulty(row.Difficulty), age)
if m.width > 0 && len(line) > m.width {
line = line[:m.width]
}
b.WriteString(line)
if i < end-1 {
b.WriteByte('\n')
}
}
return b.String()
}
func (m *ExplorerModel) viewBlockDetail() string {
if m.block == nil {
return " no block selected"
}
var b strings.Builder
meta := m.blockMeta
blk := m.block
b.WriteString(fmt.Sprintf(" Block %d\n", meta.Height))
b.WriteString(fmt.Sprintf(" Hash: %x\n", meta.Hash))
b.WriteString(fmt.Sprintf(" Timestamp: %s\n", time.Unix(int64(meta.Timestamp), 0).UTC().Format(time.RFC3339)))
b.WriteString(fmt.Sprintf(" Difficulty: %s\n", formatDifficulty(meta.Difficulty)))
b.WriteString(fmt.Sprintf(" Version: %d.%d\n", blk.MajorVersion, blk.MinorVersion))
b.WriteString(fmt.Sprintf(" Nonce: %d\n", blk.Nonce))
b.WriteString(fmt.Sprintf(" Txs: %d\n\n", len(blk.TxHashes)))
if len(blk.TxHashes) == 0 {
b.WriteString(" (coinbase only)")
} else {
b.WriteString(" Transactions:\n")
for i, txHash := range blk.TxHashes {
prefix := " "
if i == m.txCursor {
prefix = "> "
}
b.WriteString(fmt.Sprintf(" %s%x\n", prefix, txHash[:8]))
}
}
return b.String()
}
func (m *ExplorerModel) viewTxDetail() string {
if m.tx == nil {
return " no transaction selected"
}
var b strings.Builder
tx := m.tx
b.WriteString(" Transaction\n")
b.WriteString(fmt.Sprintf(" Hash: %x\n", m.txHash))
b.WriteString(fmt.Sprintf(" Version: %d\n", tx.Version))
b.WriteString(fmt.Sprintf(" Inputs: %d\n", len(tx.Vin)))
b.WriteString(fmt.Sprintf(" Outputs: %d\n\n", len(tx.Vout)))
if len(tx.Vin) > 0 {
b.WriteString(" Inputs:\n")
for i, in := range tx.Vin {
switch v := in.(type) {
case types.TxInputGenesis:
b.WriteString(fmt.Sprintf(" [%d] coinbase height=%d\n", i, v.Height))
case types.TxInputToKey:
b.WriteString(fmt.Sprintf(" [%d] to_key amount=%d key_image=%x\n", i, v.Amount, v.KeyImage[:4]))
default:
b.WriteString(fmt.Sprintf(" [%d] %T\n", i, v))
}
}
}
if len(tx.Vout) > 0 {
b.WriteString("\n Outputs:\n")
for i, out := range tx.Vout {
switch v := out.(type) {
case types.TxOutputBare:
b.WriteString(fmt.Sprintf(" [%d] bare amount=%d key=%x\n", i, v.Amount, v.Target.Key[:4]))
case types.TxOutputZarcanum:
b.WriteString(fmt.Sprintf(" [%d] zarcanum stealth=%x\n", i, v.StealthAddress[:4]))
default:
b.WriteString(fmt.Sprintf(" [%d] %T\n", i, v))
}
}
}
return b.String()
}
// loadBlocks refreshes the block list from the chain store.
// Blocks are listed from newest (top) to oldest.
func (m *ExplorerModel) loadBlocks() {
height, err := m.chain.Height()
if err != nil || height == 0 {
m.rows = nil
return
}
// Show up to 1000 most recent blocks.
count := int(height)
if count > 1000 {
count = 1000
}
rows := make([]blockRow, count)
for i := 0; i < count; i++ {
h := height - 1 - uint64(i)
blk, meta, err := m.chain.GetBlockByHeight(h)
if err != nil {
continue
}
rows[i] = blockRow{
Height: meta.Height,
Hash: meta.Hash,
TxCount: len(blk.TxHashes) + 1, // +1 for miner tx
Timestamp: meta.Timestamp,
Difficulty: meta.Difficulty,
}
}
m.rows = rows
}
// pageSize returns the number of rows to jump for page up/down.
func pageSize(height int) int {
n := height - 3
if n < 1 {
n = 1
}
return n
}