feat: add basic UI for block explorer

This commit is contained in:
Prad Nukala 2024-09-05 17:11:52 -04:00
parent 73997ad670
commit 1ec797e36f
4 changed files with 421 additions and 54 deletions

View File

@ -14,6 +14,7 @@ import (
func main() { func main() {
rootCmd := NewRootCmd() rootCmd := NewRootCmd()
rootCmd.AddCommand(tui.NewBuildProtoMsgCmd()) rootCmd.AddCommand(tui.NewBuildProtoMsgCmd())
// rootCmd.AddCommand(tui.NewExplorerCmd())
if err := svrcmd.Execute(rootCmd, "", app.DefaultNodeHome); err != nil { if err := svrcmd.Execute(rootCmd, "", app.DefaultNodeHome); err != nil {
log.NewLogger(rootCmd.OutOrStderr()).Error("failure when running app", "err", err) log.NewLogger(rootCmd.OutOrStderr()).Error("failure when running app", "err", err)

173
internal/tui/explorer.go Normal file
View File

@ -0,0 +1,173 @@
package tui
import (
"fmt"
"time"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
)
var (
subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}
highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"}
special = lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}
titleStyle = lipgloss.NewStyle().
MarginLeft(1).
MarginRight(5).
Padding(0, 1).
Italic(true).
Foreground(lipgloss.Color("#FFF7DB")).
SetString("Cosmos Block Explorer")
infoStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderTop(true).
BorderForeground(subtle)
)
type model struct {
blocks []string
transactionTable table.Model
stats map[string]string
width int
height int
}
func initialModel() model {
columns := []table.Column{
{Title: "Hash", Width: 10},
{Title: "Type", Width: 15},
{Title: "Height", Width: 10},
{Title: "Time", Width: 20},
}
rows := []table.Row{
{"abc123", "Transfer", "1000", time.Now().Format(time.RFC3339)},
{"def456", "Delegate", "999", time.Now().Add(-1 * time.Minute).Format(time.RFC3339)},
{"ghi789", "Vote", "998", time.Now().Add(-2 * time.Minute).Format(time.RFC3339)},
}
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(7),
)
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(false)
s.Selected = s.Selected.
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
t.SetStyles(s)
return model{
blocks: []string{"Block 1", "Block 2", "Block 3"},
transactionTable: t,
stats: map[string]string{
"Latest Block": "1000",
"Validators": "100",
"Bonded Tokens": "1,000,000",
},
}
}
func (m model) Init() tea.Cmd {
return tick
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "enter":
return m, tea.Batch(
tea.Printf("Selected transaction: %s", m.transactionTable.SelectedRow()[0]),
)
}
case tea.WindowSizeMsg:
m.height = msg.Height
m.width = msg.Width
case tickMsg:
// Update data here
m.blocks = append([]string{"New Block"}, m.blocks...)
if len(m.blocks) > 5 {
m.blocks = m.blocks[:5]
}
// Add a new transaction to the table
newRow := table.Row{
fmt.Sprintf("tx%d", time.Now().Unix()),
"NewTxType",
fmt.Sprintf("%d", 1000+len(m.transactionTable.Rows())),
time.Now().Format(time.RFC3339),
}
m.transactionTable.SetRows(append([]table.Row{newRow}, m.transactionTable.Rows()...))
if len(m.transactionTable.Rows()) > 10 {
m.transactionTable.SetRows(m.transactionTable.Rows()[:10])
}
return m, tick
}
m.transactionTable, cmd = m.transactionTable.Update(msg)
return m, cmd
}
func (m model) View() string {
s := titleStyle.Render("Cosmos Block Explorer")
s += "\n\n"
// Blocks
s += lipgloss.NewStyle().Bold(true).Render("Recent Blocks") + "\n"
for _, block := range m.blocks {
s += "• " + block + "\n"
}
s += "\n"
// Transactions
s += lipgloss.NewStyle().Bold(true).Render("Recent Transactions") + "\n"
s += m.transactionTable.View() + "\n\n"
// Stats
s += lipgloss.NewStyle().Bold(true).Render("Network Statistics") + "\n"
for key, value := range m.stats {
s += fmt.Sprintf("%s: %s\n", key, value)
}
return s
}
type tickMsg time.Time
func tick() tea.Msg {
time.Sleep(time.Second)
return tickMsg{}
}
func runExplorer(cmd *cobra.Command, args []string) error {
p := tea.NewProgram(initialModel(), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
return fmt.Errorf("error running explorer: %v", err)
}
return nil
}
func NewExplorerCmd() *cobra.Command {
return &cobra.Command{
Use: "cosmos-explorer",
Short: "A terminal-based Cosmos blockchain explorer",
RunE: runExplorer,
}
}

View File

@ -6,6 +6,7 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/cosmos/cosmos-sdk/codec" "github.com/cosmos/cosmos-sdk/codec"
codectypes "github.com/cosmos/cosmos-sdk/codec/types" codectypes "github.com/cosmos/cosmos-sdk/codec/types"
sdk "github.com/cosmos/cosmos-sdk/types" sdk "github.com/cosmos/cosmos-sdk/types"
@ -14,77 +15,261 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
const maxWidth = 100
var (
red = lipgloss.AdaptiveColor{Light: "#FE5F86", Dark: "#FE5F86"}
indigo = lipgloss.AdaptiveColor{Light: "#5A56E0", Dark: "#7571F9"}
green = lipgloss.AdaptiveColor{Light: "#02BA84", Dark: "#02BF87"}
)
type Styles struct {
Base,
HeaderText,
Status,
StatusHeader,
Highlight,
ErrorHeaderText,
Help lipgloss.Style
}
func NewStyles(lg *lipgloss.Renderer) *Styles {
s := Styles{}
s.Base = lg.NewStyle().
Padding(1, 2, 0, 1)
s.HeaderText = lg.NewStyle().
Foreground(indigo).
Bold(true).
Padding(0, 1, 0, 1)
s.Status = lg.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(indigo).
PaddingLeft(1).
MarginTop(1)
s.StatusHeader = lg.NewStyle().
Foreground(green).
Bold(true)
s.Highlight = lg.NewStyle().
Foreground(lipgloss.Color("212"))
s.ErrorHeaderText = s.HeaderText.
Foreground(red)
s.Help = lg.NewStyle().
Foreground(lipgloss.Color("240"))
return &s
}
type state int
const (
statusNormal state = iota
stateDone
)
type Model struct { type Model struct {
state state
lg *lipgloss.Renderer
styles *Styles
form *huh.Form form *huh.Form
width int
message *tx.TxBody message *tx.TxBody
} }
func NewModel() Model { func NewModel() Model {
return Model{ m := Model{width: maxWidth}
form: huh.NewForm( m.lg = lipgloss.DefaultRenderer()
huh.NewGroup( m.styles = NewStyles(m.lg)
huh.NewInput().
Key("from"). m.form = huh.NewForm(
Title("From Address"). huh.NewGroup(
Placeholder("cosmos1..."). huh.NewInput().
Validate(func(s string) error { Key("from").
if !strings.HasPrefix(s, "cosmos1") { Title("From Address").
return fmt.Errorf("invalid address format") Placeholder("cosmos1...").
} Validate(func(s string) error {
return nil if !strings.HasPrefix(s, "cosmos1") {
}), return fmt.Errorf("invalid address format")
huh.NewInput(). }
Key("to"). return nil
Title("To Address"). }),
Placeholder("cosmos1...").
Validate(func(s string) error { huh.NewInput().
if !strings.HasPrefix(s, "cosmos1") { Key("to").
return fmt.Errorf("invalid address format") Title("To Address").
} Placeholder("cosmos1...").
return nil Validate(func(s string) error {
}), if !strings.HasPrefix(s, "cosmos1") {
huh.NewInput(). return fmt.Errorf("invalid address format")
Key("amount"). }
Title("Amount"). return nil
Placeholder("100"). }),
Validate(func(s string) error {
if _, err := sdk.ParseCoinNormalized(s + "atom"); err != nil { huh.NewInput().
return fmt.Errorf("invalid coin amount") Key("amount").
} Title("Amount").
return nil Placeholder("100").
}), Validate(func(s string) error {
huh.NewSelect[string](). if _, err := sdk.ParseCoinNormalized(s + "atom"); err != nil {
Key("denom"). return fmt.Errorf("invalid coin amount")
Title("Denom"). }
Options(huh.NewOptions("atom", "osmo", "usnr", "snr")...), return nil
huh.NewInput(). }),
Key("memo").
Title("Memo"). huh.NewSelect[string]().
Placeholder("Optional"), Key("denom").
), Title("Denom").
Options(huh.NewOptions("atom", "osmo", "usnr", "snr")...),
huh.NewInput().
Key("memo").
Title("Memo").
Placeholder("Optional"),
huh.NewConfirm().
Key("done").
Title("Ready to convert?").
Validate(func(v bool) error {
if !v {
return fmt.Errorf("Please confirm when you're ready to convert")
}
return nil
}).
Affirmative("Yes, convert!").
Negative("Not yet"),
), ),
} ).
WithWidth(60).
WithShowHelp(false).
WithShowErrors(false)
return m
} }
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return m.form.Init() return m.form.Init()
} }
func min(x, y int) int {
if x > y {
return y
}
return x
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = min(msg.Width, maxWidth) - m.styles.Base.GetHorizontalFrameSize()
case tea.KeyMsg:
switch msg.String() {
case "esc", "ctrl+c", "q":
return m, tea.Quit
}
}
var cmds []tea.Cmd
form, cmd := m.form.Update(msg) form, cmd := m.form.Update(msg)
if f, ok := form.(*huh.Form); ok { if f, ok := form.(*huh.Form); ok {
m.form = f m.form = f
if m.form.State == huh.StateCompleted { cmds = append(cmds, cmd)
m.buildMessage()
}
} }
return m, cmd
if m.form.State == huh.StateCompleted {
m.buildMessage()
cmds = append(cmds, tea.Quit)
}
return m, tea.Batch(cmds...)
} }
func (m Model) View() string { func (m Model) View() string {
formView := m.form.View() s := m.styles
messageView := m.getMessageView()
return fmt.Sprintf("%s\n\n%s", formView, messageView) switch m.form.State {
case huh.StateCompleted:
pklCode := m.generatePkl()
messageView := m.getMessageView()
var b strings.Builder
fmt.Fprintf(&b, "Final Tx:\n\n%s\n\n%s", pklCode, messageView)
return s.Status.Margin(0, 1).Padding(1, 2).Width(80).Render(b.String()) + "\n\n"
default:
var schemaType string
if m.form.GetString("schemaType") != "" {
schemaType = "Schema Type: " + m.form.GetString("schemaType")
}
v := strings.TrimSuffix(m.form.View(), "\n\n")
form := m.lg.NewStyle().Margin(1, 0).Render(v)
var status string
{
preview := "(Preview will appear here)"
if m.form.GetString("schema") != "" {
preview = m.generatePkl()
}
const statusWidth = 40
statusMarginLeft := m.width - statusWidth - lipgloss.Width(form) - s.Status.GetMarginRight()
status = s.Status.
Height(lipgloss.Height(form)).
Width(statusWidth).
MarginLeft(statusMarginLeft).
Render(s.StatusHeader.Render("Pkl Preview") + "\n" +
schemaType + "\n\n" +
preview)
}
errors := m.form.Errors()
header := m.appBoundaryView("Sonr TX Builder")
if len(errors) > 0 {
header = m.appErrorBoundaryView(m.errorView())
}
body := lipgloss.JoinHorizontal(lipgloss.Top, form, status)
footer := m.appBoundaryView(m.form.Help().ShortHelpView(m.form.KeyBinds()))
if len(errors) > 0 {
footer = m.appErrorBoundaryView("")
}
return s.Base.Render(header + "\n" + body + "\n\n" + footer)
}
}
func (m Model) errorView() string {
var s string
for _, err := range m.form.Errors() {
s += err.Error()
}
return s
}
func (m Model) appBoundaryView(text string) string {
return lipgloss.PlaceHorizontal(
m.width,
lipgloss.Left,
m.styles.HeaderText.Render(text),
lipgloss.WithWhitespaceChars("="),
lipgloss.WithWhitespaceForeground(indigo),
)
}
func (m Model) appErrorBoundaryView(text string) string {
return lipgloss.PlaceHorizontal(
m.width,
lipgloss.Left,
m.styles.ErrorHeaderText.Render(text),
lipgloss.WithWhitespaceChars("="),
lipgloss.WithWhitespaceForeground(red),
)
}
func (m Model) generatePkl() string {
schemaType := m.form.GetString("schemaType")
schema := m.form.GetString("schema")
// This is a placeholder for the actual conversion logic
// In a real implementation, you would parse the schema and generate Pkl code
return fmt.Sprintf("// Converted from %s\n\nclass ConvertedSchema {\n // TODO: Implement conversion from %s\n // Original schema:\n /*\n%s\n */\n}", schemaType, schemaType, schema)
} }
func (m *Model) buildMessage() { func (m *Model) buildMessage() {
@ -120,7 +305,7 @@ func (m Model) getMessageView() string {
return fmt.Sprintf("Current Message:\n%s", string(jsonBytes)) return fmt.Sprintf("Current Message:\n%s", string(jsonBytes))
} }
func runTUIForm() (*tx.TxBody, error) { func RunTUIForm() (*tx.TxBody, error) {
m := NewModel() m := NewModel()
p := tea.NewProgram(m) p := tea.NewProgram(m)
@ -139,10 +324,10 @@ func runTUIForm() (*tx.TxBody, error) {
func NewBuildProtoMsgCmd() *cobra.Command { func NewBuildProtoMsgCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "build-proto-msg", Use: "dash",
Short: "Build a Cosmos SDK protobuf message using a TUI form", Short: "TUI for managing the local Sonr validator node",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
txBody, err := runTUIForm() txBody, err := RunTUIForm()
if err != nil { if err != nil {
return err return err
} }

8
pkg/builder/builder.go Normal file
View File

@ -0,0 +1,8 @@
package builder
type Builder interface {
Build() error
}
func New() Builder {
}