WIP: stuff

This commit is contained in:
2025-04-05 14:27:36 -04:00
parent 93bc18022a
commit dd0995b241
47 changed files with 6148 additions and 474 deletions

19
cli/internal/apps/msg.go Normal file
View File

@ -0,0 +1,19 @@
package apps
import "time"
type Msg struct {
Text string
Time time.Time
Key *string
Loading *bool
Success *bool
App *App
}
type App struct {
Name string
Color string
Loading *bool
}

118
cli/internal/apps/node.go Normal file
View File

@ -0,0 +1,118 @@
package apps
import (
"context"
"fmt"
"os"
"os/exec"
"sync"
"time"
"github.com/spotdemo4/trevstack/cli/internal/utils"
)
type Node struct {
App App
c chan Msg
ctx context.Context
wg *sync.WaitGroup
dir string
Cancel context.CancelFunc
Wait func()
}
func NewNode(dir string, c chan Msg) *Node {
// Create new context
ctx, cancel := context.WithCancel(context.Background())
// Create wait group
wg := sync.WaitGroup{}
node := Node{
App: App{
Name: "node",
Color: "#fab387",
},
c: c,
ctx: ctx,
wg: &wg,
dir: dir,
Cancel: cancel,
Wait: wg.Wait,
}
// Start watching
go node.dev()
return &node
}
func (n *Node) msg(m Msg) {
m.Time = time.Now()
m.App = &n.App
n.c <- m
}
func (n *Node) dev() {
n.wg.Add(1)
defer n.wg.Done()
// Create cmd
cmd := exec.Command("npm", "run", "dev")
cmd.Dir = n.dir
// Stop cmd on exit
n.wg.Add(1)
go func() {
defer n.wg.Done()
<-n.ctx.Done()
if err := cmd.Process.Signal(os.Interrupt); err != nil {
cmd.Process.Kill() // If the process is not responding to the interrupt signal, kill it
}
}()
// Start cmd
out, err := utils.Run(cmd)
if err != nil {
n.msg(Msg{
Text: err.Error(),
Success: utils.BoolPointer(false),
})
return
}
// Watch for output
for line := range out {
switch line := line.(type) {
case utils.Stdout:
n.msg(Msg{
Text: string(line),
})
case utils.Stderr:
n.msg(Msg{
Text: string(line),
Success: utils.BoolPointer(false),
})
case utils.ExitCode:
if line == 0 {
n.msg(Msg{
Text: "Node stopped",
Success: utils.BoolPointer(true),
})
} else {
n.msg(Msg{
Text: fmt.Sprintf("Node failed with exit code %d", out),
Success: utils.BoolPointer(false),
})
}
}
}
}

269
cli/internal/apps/proto.go Normal file
View File

@ -0,0 +1,269 @@
package apps
import (
"context"
"fmt"
"io/fs"
"os/exec"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"github.com/spotdemo4/trevstack/cli/internal/utils"
)
type Proto struct {
App App
c chan Msg
ctx context.Context
wg *sync.WaitGroup
watcher *fsnotify.Watcher
dir string
rootDir string
Cancel context.CancelFunc
Wait func()
}
func NewProto(dir string, rootDir string, c chan Msg) (*Proto, error) {
// Create new watcher
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
// Add directory to watcher
err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if slices.Contains(watcher.WatchList(), path) {
return nil
}
err := watcher.Add(path)
if err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
// Create new context
ctx, cancel := context.WithCancel(context.Background())
// Create wait group
wg := sync.WaitGroup{}
proto := Proto{
App: App{
Name: "proto",
Color: "#89dceb",
},
c: c,
ctx: ctx,
wg: &wg,
watcher: watcher,
dir: dir,
rootDir: rootDir,
Cancel: cancel,
Wait: wg.Wait,
}
// Start watching
go proto.watch()
return &proto, nil
}
func (p *Proto) msg(m Msg) {
m.Time = time.Now()
m.App = &p.App
p.c <- m
}
func (p *Proto) watch() {
p.wg.Add(1)
defer p.wg.Done()
defer p.watcher.Close()
// Create new rate limit map
rateLimit := make(map[string]time.Time)
p.lint()
p.msg(Msg{
Text: "Watching for proto changes...",
})
for {
select {
case <-p.ctx.Done():
return
case event, ok := <-p.watcher.Events:
if !ok {
return
}
// Rate limit
rl, ok := rateLimit[event.Name]
if ok && time.Since(rl) < 1*time.Second {
continue
}
rateLimit[event.Name] = time.Now()
p.msg(Msg{
Text: "File changed: " + strings.TrimPrefix(event.Name, p.dir),
})
ok, _ = p.lint()
if !ok {
continue
}
p.generate()
case err, ok := <-p.watcher.Errors:
if !ok {
return
}
p.msg(Msg{
Text: err.Error(),
Success: utils.BoolPointer(false),
})
}
}
}
func (p *Proto) lint() (bool, error) {
p.msg(Msg{
Text: "Linting",
Loading: utils.BoolPointer(true),
Key: utils.StringPointer("lint"),
})
// Run buf lint
cmd := exec.Command("buf", "lint")
cmd.Dir = p.rootDir
out, err := utils.Run(cmd)
if err != nil {
p.msg(Msg{
Text: err.Error(),
Success: utils.BoolPointer(false),
})
return false, err
}
// Watch for output
for line := range out {
switch line := line.(type) {
case utils.Stdout:
p.msg(Msg{
Text: string(line),
})
case utils.Stderr:
p.msg(Msg{
Text: string(line),
Success: utils.BoolPointer(false),
})
case utils.ExitCode:
if line == 0 {
p.msg(Msg{
Text: "Buf lint successful",
Success: utils.BoolPointer(true),
Loading: utils.BoolPointer(false),
Key: utils.StringPointer("lint"),
})
return true, nil
}
p.msg(Msg{
Text: fmt.Sprintf("Buf lint failed with exit code %d", out),
Success: utils.BoolPointer(false),
Loading: utils.BoolPointer(false),
Key: utils.StringPointer("lint"),
})
return false, fmt.Errorf("buf lint failed with exit code %d", line)
}
}
return false, fmt.Errorf("buf lint failed")
}
func (p *Proto) generate() error {
p.msg(Msg{
Text: "Generating proto files",
Loading: utils.BoolPointer(true),
Key: utils.StringPointer("generate"),
})
// Run buf gen
cmd := exec.Command("buf", "generate")
cmd.Dir = p.rootDir
out, err := utils.Run(cmd)
if err != nil {
p.msg(Msg{
Text: err.Error(),
Success: utils.BoolPointer(false),
})
return err
}
// Watch for output
for line := range out {
switch line := line.(type) {
case utils.Stdout:
p.msg(Msg{
Text: string(line),
})
case utils.Stderr:
p.msg(Msg{
Text: string(line),
Success: utils.BoolPointer(false),
})
case utils.ExitCode:
if line == 0 {
p.msg(Msg{
Text: "Buf generate successful",
Success: utils.BoolPointer(true),
Loading: utils.BoolPointer(false),
Key: utils.StringPointer("generate"),
})
return nil
}
p.msg(Msg{
Text: fmt.Sprintf("Buf generate failed with exit code %d", out),
Success: utils.BoolPointer(false),
Loading: utils.BoolPointer(false),
Key: utils.StringPointer("generate"),
})
return fmt.Errorf("generate failed with exit code %d", line)
}
}
return fmt.Errorf("generate failed")
}

View File

@ -0,0 +1,82 @@
package models
import (
"time"
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/lipgloss"
)
type Cbox struct {
style lipgloss.Style
Viewport *viewport.Model
maxPrefixLen int
}
func NewCbox(maxPrefixLen int) *Cbox {
s := lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("#45475a")).
BorderTop(true).
BorderBottom(true).
Margin(1, 0, 0)
return &Cbox{
style: s,
Viewport: nil,
maxPrefixLen: maxPrefixLen,
}
}
func (c *Cbox) Gen(text string, width int, height int, mtop int, mbottom int) string {
if c.Viewport == nil {
vp := viewport.New(width, height-(mtop+mbottom))
c.Viewport = &vp
c.Viewport.YPosition = mtop
c.Viewport.Style = c.style
} else {
c.Viewport.Width = width
c.Viewport.Height = height - (mtop + mbottom)
c.Viewport.YPosition = mtop
}
atBottom := c.Viewport.AtBottom()
// Need to add extra lines because of https://github.com/charmbracelet/bubbles/pull/731
c.Viewport.SetContent(text + "\n\n\n")
if atBottom {
c.Viewport.GotoBottom()
}
return c.Viewport.View()
}
func (c *Cbox) GenItem(ti time.Time, prefix string, text string, color string, width int) string {
t := lipgloss.NewStyle().
Padding(0, 1, 0, 1).
Foreground(lipgloss.Color("#a6adc8")).
Render(ti.Format(time.Kitchen))
p := lipgloss.NewStyle().
Padding(0, 1, 0, 0).
Width(c.maxPrefixLen).
Foreground(lipgloss.Color(color)).
BorderStyle(lipgloss.NormalBorder()).
BorderRight(true).
BorderForeground(lipgloss.Color(color))
m := lipgloss.NewStyle().
Padding(0, 1, 0, 1).
Foreground(lipgloss.Color("#cdd6f4")).
Width(width - lipgloss.Width(t) - lipgloss.Width(p.Render(prefix))).
Render(text)
p = p.Height(lipgloss.Height(m))
combine := lipgloss.JoinHorizontal(lipgloss.Top, t, p.Render(prefix), m)
return lipgloss.NewStyle().
Width(width).
Render(combine)
}

View File

@ -0,0 +1,33 @@
package models
import "github.com/charmbracelet/lipgloss"
type Header struct {
style lipgloss.Style
}
func NewHeader() *Header {
s := lipgloss.NewStyle().
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Bottom).
MarginTop(1)
return &Header{
style: s,
}
}
func (h *Header) Gen(width int, items ...string) string {
s := h.style.Width(width)
pp := lipgloss.JoinHorizontal(lipgloss.Center, items...)
return s.Render(pp)
}
func (h *Header) GenItem(text string) string {
return lipgloss.NewStyle().
Foreground(lipgloss.Color("#cdd6f4")).
Margin(0, 1).
Render(text)
}

View File

@ -0,0 +1,84 @@
package models
import (
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/lipgloss"
)
// keyMap defines a set of keybindings. To work for help it must satisfy
// key.Map. It could also very easily be a map[string]key.Binding.
type keyMap struct {
Up key.Binding
Down key.Binding
Left key.Binding
Right key.Binding
Help key.Binding
Quit key.Binding
}
// ShortHelp returns keybindings to be shown in the mini help view. It's part
// of the key.Map interface.
func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Help, k.Quit}
}
// FullHelp returns keybindings for the expanded help view. It's part of the
// key.Map interface.
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down, k.Left, k.Right}, // first column
{k.Help, k.Quit}, // second column
}
}
type Help struct {
keys keyMap
style lipgloss.Style
help help.Model
}
func NewHelp() *Help {
keys := keyMap{
Up: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "move up"),
),
Down: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "move down"),
),
Left: key.NewBinding(
key.WithKeys("left", "h"),
key.WithHelp("←/h", "move left"),
),
Right: key.NewBinding(
key.WithKeys("right", "l"),
key.WithHelp("→/l", "move right"),
),
Help: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "toggle help"),
),
Quit: key.NewBinding(
key.WithKeys("q", "esc", "ctrl+c"),
key.WithHelp("q", "quit"),
),
}
return &Help{
keys: keys,
style: lipgloss.NewStyle().Padding(1, 2),
help: help.New(),
}
}
func (h *Help) Gen(width int) string {
h.help.Width = width
render := h.help.View(h.keys)
return h.style.Render(render)
}
func (h *Help) Toggle() {
h.help.ShowAll = !h.help.ShowAll
}

View File

@ -0,0 +1,220 @@
package models
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/spotdemo4/trevstack/cli/internal/apps"
"github.com/spotdemo4/trevstack/cli/internal/utils"
)
type runner struct {
width *int
height *int
prefix lipgloss.Style
checkmark string
xmark string
header *Header
cbox *Cbox
help *Help
spinner spinner.Model
msgChan chan apps.Msg
msgs []apps.Msg
apps []*apps.App
}
func NewRunner(msgChan chan apps.Msg, applications []*apps.App) *runner {
prefix := lipgloss.NewStyle().
Padding(0, 1, 0, 1).
Margin(0, 1, 0, 1).
Background(lipgloss.Color("#89dceb")).
Foreground(lipgloss.Color("#11111b"))
checkmark := lipgloss.NewStyle().
Foreground(lipgloss.Color("#a6e3a1")).
Bold(true).
Render("✓")
xmark := lipgloss.NewStyle().
Foreground(lipgloss.Color("#f38ba8")).
Bold(true).
Render("✕")
mpl := 0
for _, app := range applications {
if len(app.Name) > mpl {
mpl = len(app.Name)
}
}
return &runner{
width: nil,
height: nil,
prefix: prefix,
checkmark: checkmark,
xmark: xmark,
header: NewHeader(),
cbox: NewCbox(mpl + 1),
help: NewHelp(),
spinner: spinner.New(spinner.WithSpinner(spinner.MiniDot), spinner.WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#a6adc8")))),
msgChan: msgChan,
msgs: []apps.Msg{},
apps: applications,
}
}
func (m runner) Init() tea.Cmd {
return tea.Batch(
m.spinner.Tick,
func() tea.Msg {
return apps.Msg(<-m.msgChan)
},
)
}
func (m runner) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) {
case apps.Msg:
// Remove old message with the same key
if msg.Key != nil && msg.Loading != nil && !*msg.Loading {
for i, prev := range m.msgs {
if prev.Key != nil && prev.Loading != nil && *prev.Key == *msg.Key && *prev.Loading {
m.msgs = append(m.msgs[:i], m.msgs[i+1:]...)
break
}
}
}
// Set current state
if msg.Loading != nil {
if *msg.Loading {
msg.App.Loading = nil
} else {
msg.App.Loading = msg.Success
}
}
// Append new message
m.msgs = append(m.msgs, msg)
return m, func() tea.Msg {
return apps.Msg(<-m.msgChan)
}
case spinner.TickMsg:
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case tea.WindowSizeMsg:
m.width = utils.IntPointer(msg.Width)
m.height = utils.IntPointer(msg.Height)
case tea.KeyMsg:
switch {
case key.Matches(msg, m.help.keys.Help):
m.help.Toggle()
case key.Matches(msg, m.help.keys.Quit):
return m, tea.Quit
}
case tea.MouseMsg:
switch msg.Button {
case tea.MouseButtonWheelDown:
m.cbox.Viewport.LineDown(1)
case tea.MouseButtonWheelUp:
m.cbox.Viewport.LineUp(1)
}
}
return m, tea.Batch(cmds...)
}
func (m runner) term() (rows []string) {
if m.width == nil {
return rows
}
for _, msg := range m.msgs {
item := []string{}
if msg.Loading != nil && *msg.Loading {
item = append(item, m.spinner.View())
}
if msg.Success != nil {
if *msg.Success {
item = append(item, m.checkmark)
} else {
item = append(item, m.xmark)
}
}
item = append(item, msg.Text)
itemStr := strings.Join(item, " ")
// Render the row
rows = append(rows, m.cbox.GenItem(msg.Time, msg.App.Name, itemStr, msg.App.Color, *m.width))
}
return rows
}
func (m runner) head() (items []string) {
for _, app := range m.apps {
item := []string{}
if app.Loading == nil {
item = append(item, m.spinner.View())
} else if *app.Loading {
item = append(item, m.checkmark)
} else {
item = append(item, m.xmark)
}
item = append(item, app.Name)
items = append(items, m.header.GenItem(strings.Join(item, " ")))
}
return items
}
func (m runner) View() string {
if m.width == nil || m.height == nil {
return fmt.Sprintf("\n %s Loading...", m.spinner.View())
}
// Generate the UI
header := m.header.Gen(*m.width, m.head()...)
footer := m.help.Gen(*m.width)
main := m.cbox.Gen(
strings.Join(m.term(), "\n"),
*m.width,
*m.height,
lipgloss.Height(header),
lipgloss.Height(footer),
)
s := header
s += main
s += footer
// Send the UI for rendering
return s
}

View File

@ -0,0 +1,13 @@
package utils
func BoolPointer(b bool) *bool {
return &b
}
func StringPointer(s string) *string {
return &s
}
func IntPointer(i int) *int {
return &i
}

55
cli/internal/utils/run.go Normal file
View File

@ -0,0 +1,55 @@
package utils
import (
"bufio"
"os/exec"
)
type Stdout string
type Stderr string
type ExitCode int
func Run(cmd *exec.Cmd) (chan interface{}, error) {
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
c := make(chan interface{}, 10)
go func() {
scan := bufio.NewScanner(stdout)
for scan.Scan() {
c <- Stdout(scan.Text())
}
}()
go func() {
scan := bufio.NewScanner(stderr)
for scan.Scan() {
c <- Stderr(scan.Text())
}
}()
go func() {
defer close(c)
if err := cmd.Wait(); err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
c <- ExitCode(exitError.ExitCode())
} else {
c <- ExitCode(1)
}
} else {
c <- ExitCode(0)
}
}()
return c, nil
}