1159 lines
31 KiB
Go
1159 lines
31 KiB
Go
package tui
|
||
|
||
import (
|
||
"bufio"
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"charm.land/bubbles/v2/progress"
|
||
"charm.land/bubbles/v2/spinner"
|
||
"charm.land/bubbles/v2/table"
|
||
"charm.land/bubbles/v2/textinput"
|
||
"charm.land/bubbles/v2/viewport"
|
||
tea "charm.land/bubbletea/v2"
|
||
"charm.land/lipgloss/v2"
|
||
|
||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
|
||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/platform"
|
||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/run"
|
||
)
|
||
|
||
const (
|
||
ciOK = "✓"
|
||
ciFail = "✗"
|
||
ciPend = "●"
|
||
ciNone = "–"
|
||
imgYes = "✓"
|
||
imgNo = "…"
|
||
imgCheck = "?"
|
||
imgError = "!"
|
||
imgLocal = "◆"
|
||
runMark = "▶"
|
||
idleMark = " "
|
||
)
|
||
|
||
var (
|
||
legendCIYes = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render(ciOK)
|
||
legendCINo = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render(ciFail)
|
||
legendCIPend = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Render(ciPend)
|
||
legendNone = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(ciNone)
|
||
legendImageYes = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render(imgYes)
|
||
legendImageNo = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(imgNo)
|
||
legendLocal = lipgloss.NewStyle().Foreground(lipgloss.Color("81")).Render(imgLocal)
|
||
)
|
||
|
||
type prRow struct {
|
||
kind string
|
||
pr gh.PR
|
||
local docker.Image
|
||
imageState string
|
||
running bool
|
||
port string
|
||
}
|
||
|
||
func (row prRow) hasCurrentLocalImage() bool {
|
||
return row.kind == "remote" && row.local.Tag != "" && row.pr.HeadSHA != "" && row.local.Revision == row.pr.HeadSHA
|
||
}
|
||
|
||
type prsModel struct {
|
||
table table.Model
|
||
filter textinput.Model
|
||
filtering bool
|
||
pullView viewport.Model
|
||
pulling bool
|
||
pullLines []string
|
||
pullLayers map[string]string
|
||
pullProgress progress.Model
|
||
pullCanceled bool
|
||
pullEvents <-chan pullEvent
|
||
pullPlan *run.Plan
|
||
pullCancel context.CancelFunc
|
||
rebuilding bool
|
||
rebuildCancel context.CancelFunc
|
||
logs viewport.Model
|
||
showLogs bool
|
||
followLogs bool
|
||
logsName string
|
||
spinner spinner.Model
|
||
rows []prRow
|
||
loading bool
|
||
status string
|
||
includeBots bool
|
||
demo bool
|
||
width int
|
||
height int
|
||
imageQueue []int
|
||
imageActive int
|
||
imageGen int
|
||
}
|
||
|
||
type prsLoadedMsg struct {
|
||
prs []gh.PR
|
||
images []docker.Image
|
||
prErr error
|
||
imgErr error
|
||
refresh bool
|
||
}
|
||
type imageCheckedMsg struct {
|
||
number int
|
||
exists bool
|
||
err error
|
||
gen int
|
||
}
|
||
type prActionMsg struct {
|
||
text string
|
||
}
|
||
type rebuildDoneMsg struct {
|
||
image docker.Image
|
||
err error
|
||
canceled bool
|
||
action string
|
||
}
|
||
type runningRefreshedMsg struct {
|
||
rows []prRow
|
||
generation int
|
||
}
|
||
type pullReadyMsg struct{}
|
||
type prLogTickMsg struct{}
|
||
type pullEvent struct {
|
||
line string
|
||
done bool
|
||
err error
|
||
}
|
||
|
||
type outputTail struct {
|
||
data []byte
|
||
}
|
||
|
||
func (tail *outputTail) Write(data []byte) (int, error) {
|
||
const limit = 32 * 1024
|
||
tail.data = append(tail.data, data...)
|
||
if len(tail.data) > limit {
|
||
tail.data = tail.data[len(tail.data)-limit:]
|
||
}
|
||
return len(data), nil
|
||
}
|
||
|
||
func (tail *outputTail) lastLine() string {
|
||
lines := strings.FieldsFunc(string(tail.data), func(char rune) bool { return char == '\n' || char == '\r' })
|
||
if len(lines) == 0 {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(lines[len(lines)-1])
|
||
}
|
||
|
||
func splitDockerProgress(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||
for i, b := range data {
|
||
if b == '\n' || b == '\r' {
|
||
return i + 1, data[:i], nil
|
||
}
|
||
}
|
||
if atEOF && len(data) > 0 {
|
||
return len(data), data, nil
|
||
}
|
||
return 0, nil, nil
|
||
}
|
||
|
||
func startPull(ctx context.Context, cmd *exec.Cmd, events chan<- pullEvent) tea.Cmd {
|
||
return func() tea.Msg {
|
||
reader, writer, err := os.Pipe()
|
||
if err != nil {
|
||
return pullEvent{done: true, err: err}
|
||
}
|
||
cmd.Stdout = writer
|
||
cmd.Stderr = writer
|
||
if err := cmd.Start(); err != nil {
|
||
_ = reader.Close()
|
||
_ = writer.Close()
|
||
return pullEvent{done: true, err: err}
|
||
}
|
||
_ = writer.Close()
|
||
go func() {
|
||
defer close(events)
|
||
defer reader.Close()
|
||
scanner := bufio.NewScanner(reader)
|
||
scanner.Split(splitDockerProgress)
|
||
lastLine := ""
|
||
for scanner.Scan() {
|
||
line := strings.TrimSpace(scanner.Text())
|
||
if line != "" {
|
||
lastLine = line
|
||
if ctx.Err() == nil && len(events) < cap(events)-1 {
|
||
events <- pullEvent{line: line}
|
||
}
|
||
}
|
||
}
|
||
err := cmd.Wait()
|
||
if err == nil {
|
||
err = scanner.Err()
|
||
} else {
|
||
err = commandErrorWithDetail(err, lastLine)
|
||
}
|
||
events <- pullEvent{done: true, err: err}
|
||
}()
|
||
return pullReadyMsg{}
|
||
}
|
||
}
|
||
|
||
func commandErrorWithDetail(err error, lastLine string) error {
|
||
if lastLine == "" {
|
||
return err
|
||
}
|
||
return fmt.Errorf("%w: %s", err, lastLine)
|
||
}
|
||
|
||
func waitPull(events <-chan pullEvent) tea.Cmd {
|
||
return func() tea.Msg {
|
||
event, ok := <-events
|
||
if !ok {
|
||
return nil
|
||
}
|
||
return event
|
||
}
|
||
}
|
||
|
||
func prLogTick() tea.Cmd {
|
||
return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return prLogTickMsg{} })
|
||
}
|
||
|
||
func loadDev(includeBots, refresh bool) tea.Cmd {
|
||
return func() tea.Msg {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
var prs []gh.PR
|
||
var prErr error
|
||
if refresh {
|
||
prs, prErr = gh.RefreshPRs(ctx, 50, includeBots)
|
||
} else {
|
||
prs, prErr = gh.ListPRs(ctx, 50, includeBots)
|
||
}
|
||
images, imgErr := docker.ListLocalImages(ctx)
|
||
return prsLoadedMsg{prs: prs, images: images, prErr: prErr, imgErr: imgErr, refresh: refresh}
|
||
}
|
||
}
|
||
|
||
func checkImage(number, generation int) tea.Cmd {
|
||
return func() tea.Msg {
|
||
img := fmt.Sprintf("ghcr.io/homarr-labs/homarr-test:pr-%d", number)
|
||
exists, err := docker.CheckImage(img)
|
||
return imageCheckedMsg{number: number, exists: exists, err: err, gen: generation}
|
||
}
|
||
}
|
||
|
||
func (m *prsModel) startImageChecks() tea.Cmd {
|
||
const concurrency = 6
|
||
cmds := make([]tea.Cmd, 0, concurrency)
|
||
for m.imageActive < concurrency && len(m.imageQueue) > 0 {
|
||
number := m.imageQueue[0]
|
||
m.imageQueue = m.imageQueue[1:]
|
||
m.imageActive++
|
||
cmds = append(cmds, checkImage(number, m.imageGen))
|
||
}
|
||
return tea.Batch(cmds...)
|
||
}
|
||
|
||
func ciIcon(state string) string {
|
||
switch state {
|
||
case "SUCCESS":
|
||
return ciOK
|
||
case "FAILURE":
|
||
return ciFail
|
||
case "PENDING":
|
||
return ciPend
|
||
default:
|
||
return ciNone
|
||
}
|
||
}
|
||
|
||
func buildTableRows(rows []prRow) []table.Row {
|
||
out := make([]table.Row, 0, len(rows))
|
||
for _, r := range rows {
|
||
mark := idleMark
|
||
port := ""
|
||
if r.running {
|
||
mark = runMark
|
||
port = r.port
|
||
}
|
||
img := imgCheck
|
||
switch r.imageState {
|
||
case "yes":
|
||
img = imgYes
|
||
case "no":
|
||
img = imgNo
|
||
case "error":
|
||
img = imgError
|
||
}
|
||
if r.hasCurrentLocalImage() {
|
||
img = imgLocal
|
||
}
|
||
source := "remote"
|
||
ref := fmt.Sprint(r.pr.Number)
|
||
title := r.pr.Title
|
||
author := r.pr.Author
|
||
ci := ciIcon(r.pr.CIState)
|
||
if r.kind == "local" {
|
||
source = "local"
|
||
ref = r.local.Tag
|
||
title = r.local.Reference()
|
||
author = "untracked build"
|
||
if r.local.Source != "" {
|
||
author = filepath.Base(r.local.Source)
|
||
}
|
||
ci = ciNone
|
||
} else if r.pr.IsDraft {
|
||
title = "DRAFT · " + title
|
||
}
|
||
if len([]rune(title)) > 44 {
|
||
title = string([]rune(title)[:43]) + "…"
|
||
}
|
||
out = append(out, table.Row{
|
||
mark,
|
||
source,
|
||
ref,
|
||
title,
|
||
author,
|
||
ci,
|
||
img,
|
||
port,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func developmentRows(prs []gh.PR, images []docker.Image, imageStates map[int]string) ([]prRow, []int) {
|
||
rows := make([]prRow, 0, len(prs)+len(images))
|
||
prIndexes := make(map[int]int, len(prs))
|
||
queue := make([]int, 0, len(prs))
|
||
for _, pr := range prs {
|
||
imageState := imageStates[pr.Number]
|
||
prIndexes[pr.Number] = len(rows)
|
||
rows = append(rows, prRow{kind: "remote", pr: pr, imageState: imageState})
|
||
}
|
||
for _, image := range images {
|
||
if index, exists := prIndexes[image.PRNumber]; exists && image.Revision != "" && image.Revision == rows[index].pr.HeadSHA {
|
||
if rows[index].local.Tag == "" {
|
||
rows[index].local = image
|
||
}
|
||
continue
|
||
}
|
||
rows = append(rows, prRow{kind: "local", local: image, imageState: "yes"})
|
||
}
|
||
for index := range rows {
|
||
if rows[index].kind != "remote" || rows[index].imageState != "" {
|
||
continue
|
||
}
|
||
if rows[index].hasCurrentLocalImage() {
|
||
rows[index].imageState = "unchecked"
|
||
continue
|
||
}
|
||
rows[index].imageState = "checking"
|
||
queue = append(queue, rows[index].pr.Number)
|
||
}
|
||
return rows, queue
|
||
}
|
||
|
||
func prColumns(width int) []table.Column {
|
||
authorWidth := 16
|
||
portWidth := 6
|
||
if width < 90 {
|
||
authorWidth = 0
|
||
}
|
||
if width < 72 {
|
||
portWidth = 0
|
||
}
|
||
titleWidth := max(min(width-60, 45), 18)
|
||
return []table.Column{
|
||
{Title: "", Width: 2},
|
||
{Title: "SOURCE", Width: 8},
|
||
{Title: "REF", Width: 12},
|
||
{Title: "TITLE", Width: titleWidth},
|
||
{Title: "AUTHOR", Width: authorWidth},
|
||
{Title: "CI", Width: 3},
|
||
{Title: "IMAGE", Width: 5},
|
||
{Title: "PORT", Width: portWidth},
|
||
}
|
||
}
|
||
|
||
func newPRsModel() prsModel {
|
||
t := table.New(
|
||
table.WithColumns(prColumns(110)),
|
||
table.WithFocused(true),
|
||
table.WithHeight(18),
|
||
table.WithWidth(110),
|
||
)
|
||
s := table.DefaultStyles()
|
||
s.Header = s.Header.Bold(true)
|
||
s.Selected = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("57"))
|
||
t.SetStyles(s)
|
||
|
||
sp := spinner.New()
|
||
sp.Spinner = spinner.Dot
|
||
filter := textinput.New()
|
||
filter.Prompt = "/ "
|
||
filter.Placeholder = "filter source, PR, image, title, author, branch, CI, port…"
|
||
filter.SetWidth(65)
|
||
filterStyles := textinput.DefaultDarkStyles()
|
||
filterStyles.Focused.Prompt = filterStyles.Focused.Prompt.Foreground(lipgloss.Color("212")).Bold(true)
|
||
filterStyles.Focused.Text = filterStyles.Focused.Text.Foreground(lipgloss.Color("229"))
|
||
filter.SetStyles(filterStyles)
|
||
pullView := viewport.New(viewport.WithWidth(110), viewport.WithHeight(7))
|
||
pullView.SoftWrap = true
|
||
pullView.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("250"))
|
||
pullProgress := progress.New(progress.WithWidth(60), progress.WithColors(lipgloss.Color("212")))
|
||
logs := viewport.New(viewport.WithWidth(110), viewport.WithHeight(7))
|
||
logs.SoftWrap = true
|
||
logs.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("250"))
|
||
|
||
return prsModel{table: t, filter: filter, pullView: pullView, pullLayers: make(map[string]string), pullProgress: pullProgress, logs: logs, followLogs: true, spinner: sp, loading: true, status: "loading development images…", width: 110, height: 32}
|
||
}
|
||
|
||
func (m *prsModel) layout() {
|
||
reserved := 12
|
||
if m.filtering || m.filter.Value() != "" {
|
||
reserved += 2
|
||
}
|
||
if m.pulling {
|
||
reserved += 9
|
||
}
|
||
if m.showLogs {
|
||
reserved += 9
|
||
}
|
||
m.table.SetHeight(max(m.height-reserved, 6))
|
||
m.pullView.SetWidth(max(m.width-2, 20))
|
||
m.pullView.SetHeight(7)
|
||
m.pullProgress.SetWidth(max(min(m.width-12, 70), 20))
|
||
m.logs.SetWidth(max(m.width-2, 20))
|
||
m.logs.SetHeight(7)
|
||
}
|
||
|
||
func (m *prsModel) applyFilter() {
|
||
rendered := buildTableRows(m.rows)
|
||
query := strings.ToLower(strings.TrimSpace(m.filter.Value()))
|
||
if query == "" {
|
||
m.table.SetRows(rendered)
|
||
return
|
||
}
|
||
rows := make([]table.Row, 0, len(rendered))
|
||
for i, row := range m.rows {
|
||
terms := []string{
|
||
row.kind,
|
||
fmt.Sprint(row.pr.Number),
|
||
fmt.Sprintf("#%d", row.pr.Number),
|
||
fmt.Sprintf("pr-%d", row.pr.Number),
|
||
row.pr.Title,
|
||
row.pr.Author,
|
||
row.pr.HeadRef,
|
||
row.pr.CIState,
|
||
row.imageState,
|
||
row.port,
|
||
row.local.Tag,
|
||
row.local.Source,
|
||
row.local.Revision,
|
||
}
|
||
if row.pr.IsDraft {
|
||
terms = append(terms, "draft")
|
||
}
|
||
if row.running {
|
||
terms = append(terms, "running started")
|
||
}
|
||
if strings.Contains(strings.ToLower(strings.Join(terms, " ")), query) {
|
||
rows = append(rows, rendered[i])
|
||
}
|
||
}
|
||
m.table.SetRows(rows)
|
||
}
|
||
|
||
func (m prsModel) selectedRow() *prRow {
|
||
selected := m.table.SelectedRow()
|
||
if len(selected) < 3 {
|
||
return nil
|
||
}
|
||
for i := range m.rows {
|
||
if m.rows[i].kind == selected[1] && ((selected[1] == "remote" && fmt.Sprint(m.rows[i].pr.Number) == selected[2]) || (selected[1] == "local" && m.rows[i].local.Tag == selected[2])) {
|
||
return &m.rows[i]
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (m prsModel) selectedPR() *prRow {
|
||
row := m.selectedRow()
|
||
if row == nil || row.kind != "remote" {
|
||
return nil
|
||
}
|
||
return row
|
||
}
|
||
|
||
func localBuildForRow(row prRow) (docker.Image, string, string) {
|
||
if row.kind == "remote" {
|
||
image := docker.Image{Tag: fmt.Sprintf("pr-%d", row.pr.Number), PRNumber: row.pr.Number}
|
||
return image, "built", fmt.Sprintf("building PR #%d locally as %s…", row.pr.Number, image.Reference())
|
||
}
|
||
return row.local, "rebuilt", "rebuilding " + row.local.Reference() + "…"
|
||
}
|
||
|
||
func localPRPlan(row prRow, demo bool, findPort func(int) int) (*run.Plan, error) {
|
||
plan, err := run.BuildPlan(run.Options{Context: context.Background(), Tag: row.local.Tag, Demo: demo, FindPort: findPort})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
plan.Name = fmt.Sprintf("homarr_pr_%d", row.pr.Number)
|
||
plan.Volume = fmt.Sprintf("homarr_pr_%d_data", row.pr.Number)
|
||
plan.Label = fmt.Sprintf("PR #%d · local %s", row.pr.Number, truncateText(row.pr.HeadSHA, 12))
|
||
plan.PRNumber = row.pr.Number
|
||
return plan, nil
|
||
}
|
||
|
||
func (m *prsModel) refreshSelectedLogs() tea.Cmd {
|
||
row := m.selectedRow()
|
||
if row == nil || !row.running {
|
||
m.logsName = ""
|
||
m.logs.SetContent("selected image is not running")
|
||
return nil
|
||
}
|
||
name := fmt.Sprintf("homarr_pr_%d", row.pr.Number)
|
||
if row.kind == "local" {
|
||
name = "homarr_" + row.local.Tag
|
||
}
|
||
if name != m.logsName {
|
||
m.logsName = name
|
||
m.logs.SetContent("loading logs…")
|
||
}
|
||
return fetchLogs(name)
|
||
}
|
||
|
||
func (m *prsModel) beginPull(row prRow) tea.Cmd {
|
||
plan, err := run.BuildPlan(run.Options{Context: context.Background(), PR: row.pr.Number, Demo: m.demo})
|
||
if err != nil {
|
||
m.status = err.Error()
|
||
return nil
|
||
}
|
||
if row.running && row.port != "" {
|
||
if port, err := strconv.Atoi(row.port); err == nil {
|
||
plan.HostPort = port
|
||
}
|
||
}
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
events := make(chan pullEvent, 128)
|
||
m.pulling = true
|
||
m.pullPlan = plan
|
||
m.pullCancel = cancel
|
||
m.pullEvents = events
|
||
m.pullLines = nil
|
||
m.pullLayers = make(map[string]string)
|
||
m.pullCanceled = false
|
||
m.pullView.SetContent("")
|
||
m.showLogs = false
|
||
m.logsName = ""
|
||
if row.running {
|
||
m.status = fmt.Sprintf("pulling latest image and redeploying PR #%d…", row.pr.Number)
|
||
} else {
|
||
m.status = fmt.Sprintf("pulling latest image for PR #%d…", row.pr.Number)
|
||
}
|
||
m.layout()
|
||
return tea.Batch(startPull(ctx, docker.PullCommandContext(ctx, plan.Image, plan.Platform), events), m.spinner.Tick)
|
||
}
|
||
|
||
func (m *prsModel) appendPullLine(line string) {
|
||
const retainedLines = 40
|
||
m.pullLines = append(m.pullLines, line)
|
||
if len(m.pullLines) > retainedLines {
|
||
m.pullLines = m.pullLines[len(m.pullLines)-retainedLines:]
|
||
}
|
||
m.pullView.SetContent(strings.Join(m.pullLines, "\n"))
|
||
m.pullView.GotoBottom()
|
||
parts := strings.SplitN(line, ":", 2)
|
||
if len(parts) == 2 {
|
||
layer := strings.TrimSpace(parts[0])
|
||
if len(layer) == 12 {
|
||
m.pullLayers[layer] = strings.TrimSpace(parts[1])
|
||
}
|
||
}
|
||
}
|
||
|
||
func (m prsModel) pullCompletion() (complete, total int, percent float64) {
|
||
for _, state := range m.pullLayers {
|
||
total++
|
||
lower := strings.ToLower(state)
|
||
if strings.Contains(lower, "pull complete") || strings.Contains(lower, "already exists") {
|
||
complete++
|
||
}
|
||
}
|
||
if total > 0 {
|
||
percent = float64(complete) / float64(total)
|
||
}
|
||
return complete, total, percent
|
||
}
|
||
|
||
func (m prsModel) Init() tea.Cmd {
|
||
return tea.Batch(loadDev(m.includeBots, false), m.spinner.Tick)
|
||
}
|
||
|
||
func refreshRunning(rows []prRow, generation int) tea.Cmd {
|
||
return func() tea.Msg {
|
||
containers, _ := docker.List()
|
||
updated := append([]prRow(nil), rows...)
|
||
for index, row := range updated {
|
||
name := fmt.Sprintf("homarr_pr_%d", row.pr.Number)
|
||
if row.kind == "local" {
|
||
name = "homarr_" + row.local.Tag
|
||
}
|
||
row.running = false
|
||
row.port = ""
|
||
for _, container := range containers {
|
||
if container.Name == name && container.Running() {
|
||
row.running = true
|
||
row.port = container.HostPort()
|
||
}
|
||
}
|
||
updated[index] = row
|
||
}
|
||
return runningRefreshedMsg{rows: updated, generation: generation}
|
||
}
|
||
}
|
||
|
||
func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tea.WindowSizeMsg:
|
||
m.width = msg.Width
|
||
m.height = msg.Height
|
||
m.table.SetColumns(prColumns(msg.Width))
|
||
m.table.SetWidth(msg.Width)
|
||
m.filter.SetWidth(max(min(msg.Width-4, 75), 10))
|
||
m.layout()
|
||
return m, nil
|
||
case prsLoadedMsg:
|
||
if msg.prErr != nil && msg.imgErr != nil {
|
||
m.loading = false
|
||
m.status = "failed to load development images: " + msg.prErr.Error() + "; " + msg.imgErr.Error()
|
||
return m, nil
|
||
}
|
||
m.loading = false
|
||
m.status = fmt.Sprintf("%d remote PRs · %d local images", len(msg.prs), len(msg.images))
|
||
if msg.prErr != nil {
|
||
m.status = "remote PRs unavailable: " + msg.prErr.Error()
|
||
} else if msg.imgErr != nil {
|
||
m.status = "local images unavailable: " + msg.imgErr.Error()
|
||
}
|
||
imageStates := make(map[int]string, len(m.rows))
|
||
if !msg.refresh || msg.prErr != nil {
|
||
for _, row := range m.rows {
|
||
if row.kind == "remote" && (row.imageState == "yes" || row.imageState == "no") {
|
||
imageStates[row.pr.Number] = row.imageState
|
||
}
|
||
}
|
||
}
|
||
m.rows, m.imageQueue = developmentRows(msg.prs, msg.images, imageStates)
|
||
m.imageGen++
|
||
m.imageActive = 0
|
||
m.applyFilter()
|
||
return m, tea.Batch(m.startImageChecks(), refreshRunning(m.rows, m.imageGen))
|
||
case imageCheckedMsg:
|
||
if msg.gen != m.imageGen {
|
||
return m, nil
|
||
}
|
||
m.imageActive--
|
||
for i, r := range m.rows {
|
||
if r.pr.Number == msg.number {
|
||
if msg.err != nil {
|
||
m.rows[i].imageState = "error"
|
||
m.status = msg.err.Error()
|
||
} else if msg.exists {
|
||
m.rows[i].imageState = "yes"
|
||
} else {
|
||
m.rows[i].imageState = "no"
|
||
}
|
||
}
|
||
}
|
||
m.applyFilter()
|
||
return m, m.startImageChecks()
|
||
case prActionMsg:
|
||
m.status = msg.text
|
||
return m, refreshRunning(m.rows, m.imageGen)
|
||
case runningRefreshedMsg:
|
||
if msg.generation != m.imageGen {
|
||
return m, nil
|
||
}
|
||
m.rows = msg.rows
|
||
m.applyFilter()
|
||
return m, m.refreshSelectedLogs()
|
||
case rebuildDoneMsg:
|
||
m.rebuilding = false
|
||
if m.rebuildCancel != nil {
|
||
m.rebuildCancel()
|
||
}
|
||
m.rebuildCancel = nil
|
||
m.loading = true
|
||
action := msg.action
|
||
if action == "" {
|
||
action = "rebuilt"
|
||
}
|
||
if msg.canceled {
|
||
m.status = action + " canceled"
|
||
} else if msg.err != nil {
|
||
m.status = action + " failed: " + msg.err.Error()
|
||
} else {
|
||
m.status = action + " " + msg.image.Reference()
|
||
}
|
||
return m, tea.Batch(loadDev(m.includeBots, true), m.spinner.Tick)
|
||
case logsMsg:
|
||
if !m.showLogs || msg.name == "" || msg.name != m.logsName {
|
||
return m, nil
|
||
}
|
||
m.logs.SetContent(strings.TrimRight(msg.content, "\n"))
|
||
if m.followLogs {
|
||
m.logs.GotoBottom()
|
||
}
|
||
return m, nil
|
||
case prLogTickMsg:
|
||
if !m.showLogs {
|
||
return m, nil
|
||
}
|
||
return m, tea.Batch(m.refreshSelectedLogs(), prLogTick())
|
||
case pullReadyMsg:
|
||
return m, waitPull(m.pullEvents)
|
||
case pullEvent:
|
||
if !msg.done {
|
||
m.appendPullLine(msg.line)
|
||
return m, waitPull(m.pullEvents)
|
||
}
|
||
m.pulling = false
|
||
m.pullEvents = nil
|
||
if m.pullCancel != nil {
|
||
m.pullCancel()
|
||
}
|
||
m.pullCancel = nil
|
||
m.layout()
|
||
if msg.err != nil {
|
||
if m.pullCanceled {
|
||
m.status = "pull canceled"
|
||
} else {
|
||
m.status = "pull failed: " + msg.err.Error()
|
||
}
|
||
m.pullCanceled = false
|
||
m.pullPlan = nil
|
||
return m, nil
|
||
}
|
||
m.pullCanceled = false
|
||
plan := m.pullPlan
|
||
m.pullPlan = nil
|
||
if plan == nil {
|
||
return m, nil
|
||
}
|
||
for i := range m.rows {
|
||
if m.rows[i].pr.Number == plan.PRNumber {
|
||
m.rows[i].imageState = "yes"
|
||
}
|
||
}
|
||
m.applyFilter()
|
||
m.status = fmt.Sprintf("starting PR #%d…", plan.PRNumber)
|
||
return m, func() tea.Msg {
|
||
plan.Pull = false
|
||
if err := run.StartDetached(plan); err != nil {
|
||
return prActionMsg{"start failed: " + err.Error()}
|
||
}
|
||
return prActionMsg{fmt.Sprintf("started PR #%d → http://localhost:%d", plan.PRNumber, plan.HostPort)}
|
||
}
|
||
case spinner.TickMsg:
|
||
var cmd tea.Cmd
|
||
m.spinner, cmd = m.spinner.Update(msg)
|
||
if m.loading || m.pulling || m.rebuilding {
|
||
return m, cmd
|
||
}
|
||
return m, nil
|
||
case tea.KeyPressMsg:
|
||
if m.rebuilding {
|
||
switch msg.String() {
|
||
case "q", "ctrl+c", "esc":
|
||
if m.rebuildCancel != nil {
|
||
m.rebuildCancel()
|
||
}
|
||
m.status = "canceling rebuild…"
|
||
return m, nil
|
||
}
|
||
}
|
||
if m.pulling {
|
||
switch msg.String() {
|
||
case "q", "ctrl+c", "esc":
|
||
if m.pullCancel != nil {
|
||
m.pullCanceled = true
|
||
m.pullCancel()
|
||
}
|
||
m.status = "canceling pull…"
|
||
}
|
||
return m, nil
|
||
}
|
||
if m.filtering {
|
||
switch msg.Key().Code {
|
||
case tea.KeyEscape:
|
||
m.filtering = false
|
||
m.filter.Reset()
|
||
m.filter.Blur()
|
||
m.applyFilter()
|
||
m.layout()
|
||
return m, nil
|
||
case tea.KeyEnter:
|
||
m.filtering = false
|
||
m.filter.Blur()
|
||
m.layout()
|
||
return m, nil
|
||
}
|
||
var cmd tea.Cmd
|
||
m.filter, cmd = m.filter.Update(msg)
|
||
m.applyFilter()
|
||
return m, cmd
|
||
}
|
||
switch msg.Key().Code {
|
||
case tea.KeyUp, tea.KeyKpUp:
|
||
m.table.MoveUp(1)
|
||
return m, m.refreshSelectedLogs()
|
||
case tea.KeyDown, tea.KeyKpDown:
|
||
m.table.MoveDown(1)
|
||
return m, m.refreshSelectedLogs()
|
||
case tea.KeyPgUp, tea.KeyKpPgUp:
|
||
if m.showLogs {
|
||
m.followLogs = false
|
||
m.logs.PageUp()
|
||
}
|
||
return m, nil
|
||
case tea.KeyPgDown, tea.KeyKpPgDown:
|
||
if m.showLogs {
|
||
m.logs.PageDown()
|
||
m.followLogs = m.logs.AtBottom()
|
||
}
|
||
return m, nil
|
||
}
|
||
switch msg.String() {
|
||
case "q", "ctrl+c", "esc":
|
||
return m, tea.Quit
|
||
case "enter", "space":
|
||
row := m.selectedRow()
|
||
if row == nil {
|
||
return m, nil
|
||
}
|
||
name := fmt.Sprintf("homarr_pr_%d", row.pr.Number)
|
||
label := fmt.Sprintf("PR #%d", row.pr.Number)
|
||
if row.kind == "local" {
|
||
name = "homarr_" + row.local.Tag
|
||
label = row.local.Reference()
|
||
}
|
||
if row.running {
|
||
return m, func() tea.Msg {
|
||
if err := docker.Stop(name); err != nil {
|
||
return prActionMsg{"stop failed: " + err.Error()}
|
||
}
|
||
return prActionMsg{"stopped " + label}
|
||
}
|
||
}
|
||
if row.kind == "remote" {
|
||
if row.hasCurrentLocalImage() {
|
||
plan, err := localPRPlan(*row, m.demo, nil)
|
||
if err != nil {
|
||
m.status = err.Error()
|
||
return m, nil
|
||
}
|
||
m.status = fmt.Sprintf("starting PR #%d from local build…", row.pr.Number)
|
||
return m, func() tea.Msg {
|
||
if err := run.StartDetached(plan); err != nil {
|
||
return prActionMsg{"start failed: " + err.Error()}
|
||
}
|
||
return prActionMsg{fmt.Sprintf("started PR #%d from local build → http://localhost:%d", row.pr.Number, plan.HostPort)}
|
||
}
|
||
}
|
||
if row.imageState == "checking" {
|
||
m.status = fmt.Sprintf("PR #%d image check is still in progress", row.pr.Number)
|
||
return m, nil
|
||
}
|
||
return m, m.beginPull(*row)
|
||
}
|
||
plan, err := run.BuildPlan(run.Options{Context: context.Background(), Tag: row.local.Tag, Demo: m.demo})
|
||
if err != nil {
|
||
m.status = err.Error()
|
||
return m, nil
|
||
}
|
||
return m, func() tea.Msg {
|
||
if err := run.StartDetached(plan); err != nil {
|
||
return prActionMsg{"start failed: " + err.Error()}
|
||
}
|
||
return prActionMsg{fmt.Sprintf("started %s → http://localhost:%d", label, plan.HostPort)}
|
||
}
|
||
case "r":
|
||
m.loading = true
|
||
m.status = "refreshing…"
|
||
return m, tea.Batch(loadDev(m.includeBots, true), m.spinner.Tick)
|
||
case "/":
|
||
m.filtering = true
|
||
m.layout()
|
||
return m, m.filter.Focus()
|
||
case "o":
|
||
row := m.selectedPR()
|
||
if row != nil {
|
||
if err := platform.OpenURL(fmt.Sprintf("https://github.com/homarr-labs/homarr/pull/%d", row.pr.Number)); err != nil {
|
||
m.status = "open failed: " + err.Error()
|
||
}
|
||
}
|
||
case "a":
|
||
row := m.selectedRow()
|
||
if row != nil && row.running && row.port != "" {
|
||
if err := platform.OpenURL("http://localhost:" + row.port); err != nil {
|
||
m.status = "open failed: " + err.Error()
|
||
}
|
||
} else {
|
||
m.status = "selected image is not running"
|
||
}
|
||
case "l":
|
||
m.showLogs = !m.showLogs
|
||
m.layout()
|
||
if m.showLogs {
|
||
m.followLogs = true
|
||
return m, tea.Batch(m.refreshSelectedLogs(), prLogTick())
|
||
}
|
||
return m, nil
|
||
case "f":
|
||
if m.showLogs {
|
||
m.followLogs = !m.followLogs
|
||
if m.followLogs {
|
||
m.logs.GotoBottom()
|
||
}
|
||
} else {
|
||
m.status = "open logs with l first"
|
||
}
|
||
case "p":
|
||
row := m.selectedPR()
|
||
if row != nil {
|
||
return m, m.beginPull(*row)
|
||
} else {
|
||
m.status = "pull is only available for remote PR images"
|
||
}
|
||
case "R":
|
||
if m.rebuilding {
|
||
m.status = "a local build is already running"
|
||
return m, nil
|
||
}
|
||
row := m.selectedRow()
|
||
if row == nil {
|
||
m.status = "select an image to build"
|
||
return m, nil
|
||
}
|
||
image, action, status := localBuildForRow(*row)
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
m.rebuilding = true
|
||
m.rebuildCancel = cancel
|
||
m.status = status
|
||
rebuild := func() tea.Msg {
|
||
output := &outputTail{}
|
||
err := run.RebuildImageContext(ctx, image, output, output)
|
||
if err != nil {
|
||
err = commandErrorWithDetail(err, output.lastLine())
|
||
}
|
||
return rebuildDoneMsg{image: image, err: err, canceled: ctx.Err() != nil, action: action}
|
||
}
|
||
return m, tea.Batch(rebuild, m.spinner.Tick)
|
||
case "m":
|
||
m.demo = !m.demo
|
||
if m.demo {
|
||
m.status = "demo mode enabled for the next launch"
|
||
} else {
|
||
m.status = "demo mode disabled"
|
||
}
|
||
case "k":
|
||
m.table.MoveUp(1)
|
||
return m, m.refreshSelectedLogs()
|
||
case "j":
|
||
m.table.MoveDown(1)
|
||
return m, m.refreshSelectedLogs()
|
||
case "b":
|
||
m.includeBots = !m.includeBots
|
||
m.loading = true
|
||
if m.includeBots {
|
||
m.status = "refreshing (bots shown)…"
|
||
} else {
|
||
m.status = "refreshing (bots hidden)…"
|
||
}
|
||
return m, tea.Batch(loadDev(m.includeBots, false), m.spinner.Tick)
|
||
}
|
||
}
|
||
var cmd tea.Cmd
|
||
m.table, cmd = m.table.Update(msg)
|
||
return m, cmd
|
||
}
|
||
|
||
func coloredCI(state string) string {
|
||
switch state {
|
||
case "SUCCESS":
|
||
return lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("✓ success")
|
||
case "FAILURE":
|
||
return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("✗ failed")
|
||
case "PENDING":
|
||
return lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Render("● pending")
|
||
default:
|
||
return lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("– no checks")
|
||
}
|
||
}
|
||
|
||
func coloredImage(state string) string {
|
||
switch state {
|
||
case "yes":
|
||
return lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("✓ available")
|
||
case "no":
|
||
return lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("… not built")
|
||
case "error":
|
||
return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("! check failed")
|
||
default:
|
||
return lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Render("? checking")
|
||
}
|
||
}
|
||
|
||
func (m prsModel) selectedDetailView() string {
|
||
row := m.selectedRow()
|
||
if row == nil {
|
||
return ""
|
||
}
|
||
if row.kind == "local" {
|
||
source := row.local.Source
|
||
if source == "" {
|
||
source = "unknown source · rebuild unavailable"
|
||
}
|
||
meta := "local · " + source
|
||
if row.local.Revision != "" {
|
||
meta += " · " + truncateText(row.local.Revision, 12)
|
||
}
|
||
if row.local.PRNumber > 0 {
|
||
meta += fmt.Sprintf(" · PR #%d", row.local.PRNumber)
|
||
}
|
||
if row.running {
|
||
meta += " · " + lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("running on :"+row.port)
|
||
}
|
||
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229")).Render(row.local.Reference())
|
||
return title + "\n" + meta
|
||
}
|
||
branch := row.pr.HeadRef
|
||
if branch == "" {
|
||
branch = "unknown branch"
|
||
}
|
||
runSource := "run remote"
|
||
if row.hasCurrentLocalImage() {
|
||
runSource = "run local " + truncateText(row.local.Revision, 12)
|
||
}
|
||
meta := "@" + row.pr.Author + " · " + branch + " · CI " + coloredCI(row.pr.CIState) + " · " + runSource
|
||
if !row.hasCurrentLocalImage() {
|
||
meta += " · GHCR " + coloredImage(row.imageState)
|
||
}
|
||
if row.running {
|
||
meta += " · " + lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("running on :"+row.port)
|
||
}
|
||
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229")).Render(fmt.Sprintf("#%d %s", row.pr.Number, row.pr.Title))
|
||
return title + "\n" + meta
|
||
}
|
||
|
||
func truncateText(value string, length int) string {
|
||
runes := []rune(value)
|
||
if len(runes) <= length {
|
||
return value
|
||
}
|
||
return string(runes[:length])
|
||
}
|
||
|
||
func (m prsModel) View() tea.View {
|
||
var b strings.Builder
|
||
header := titleStyle.Render("🦞 homarr development")
|
||
if m.loading || m.rebuilding {
|
||
header += " " + m.spinner.View()
|
||
}
|
||
running := 0
|
||
remote := 0
|
||
local := 0
|
||
for _, row := range m.rows {
|
||
if row.kind == "local" {
|
||
local++
|
||
} else {
|
||
remote++
|
||
if row.hasCurrentLocalImage() {
|
||
local++
|
||
}
|
||
}
|
||
if row.running {
|
||
running++
|
||
}
|
||
}
|
||
header += " " + helpStyle.Render(fmt.Sprintf("%d remote · %d local · %d running · %d shown", remote, local, running, len(m.table.Rows())))
|
||
if m.demo {
|
||
header += " " + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render("DEMO")
|
||
}
|
||
b.WriteString(header + "\n\n")
|
||
if m.filtering || m.filter.Value() != "" {
|
||
b.WriteString(m.filter.View() + "\n\n")
|
||
}
|
||
if len(m.table.Rows()) > 0 {
|
||
b.WriteString(m.table.View() + "\n")
|
||
} else if len(m.rows) > 0 {
|
||
b.WriteString(helpStyle.Render("no development images match "+m.filter.Value()) + "\n")
|
||
}
|
||
if detail := m.selectedDetailView(); detail != "" {
|
||
b.WriteString("\n" + detail + "\n")
|
||
}
|
||
if m.pulling {
|
||
label := "Pulling image"
|
||
if m.pullPlan != nil {
|
||
label = fmt.Sprintf("Pulling PR #%d", m.pullPlan.PRNumber)
|
||
}
|
||
b.WriteString("\n" + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(m.spinner.View()+" "+label) + "\n")
|
||
complete, total, percent := m.pullCompletion()
|
||
if total > 0 {
|
||
b.WriteString(m.pullProgress.ViewAs(percent) + " " + helpStyle.Render(fmt.Sprintf("%d/%d layers", complete, total)) + "\n")
|
||
}
|
||
b.WriteString(m.pullView.View() + "\n")
|
||
b.WriteString(helpStyle.Render("q/esc cancel download") + "\n")
|
||
}
|
||
if m.showLogs {
|
||
mode := "paused"
|
||
if m.followLogs {
|
||
mode = "following"
|
||
}
|
||
name := m.logsName
|
||
if name == "" {
|
||
name = "selected image"
|
||
}
|
||
b.WriteString("\n" + logHeadSt.Render("── logs: "+name+" ("+mode+") ──") + "\n")
|
||
b.WriteString(m.logs.View() + "\n")
|
||
}
|
||
if m.status != "" {
|
||
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
||
lower := strings.ToLower(m.status)
|
||
if strings.Contains(lower, "fail") || strings.Contains(lower, "error") || strings.Contains(lower, "cannot") {
|
||
statusStyle = statusStyle.Foreground(lipgloss.Color("196"))
|
||
} else if m.pulling || m.rebuilding || strings.Contains(lower, "starting") || strings.Contains(lower, "refreshing") {
|
||
statusStyle = statusStyle.Foreground(lipgloss.Color("214"))
|
||
}
|
||
b.WriteString("\n" + statusStyle.Render(m.status))
|
||
}
|
||
botState := "bots hidden"
|
||
if m.includeBots {
|
||
botState = "bots shown"
|
||
}
|
||
if !m.pulling {
|
||
b.WriteString("\n\n" + helpStyle.Render("/ filter · ↑/↓ select · enter start/stop · R build locally · p pull remote · m demo · o PR · a app"))
|
||
b.WriteString("\n" + helpStyle.Render("b "+botState+" · r refresh · d instances · q quit"))
|
||
b.WriteString("\n" + helpStyle.Render("l toggle logs · f follow · pgup/pgdn scroll"))
|
||
if m.rebuilding {
|
||
b.WriteString("\n" + helpStyle.Render("local build running in background · q/esc cancel build"))
|
||
}
|
||
}
|
||
b.WriteString("\n" + helpStyle.Render("SOURCE: local Docker / remote GHCR · CI: "+legendCIYes+" pass "+legendCINo+" fail "+legendCIPend+" pending "+legendNone+" none · IMAGE: "+legendLocal+" local "+legendImageYes+" remote "+legendImageNo+" not built"))
|
||
v := tea.NewView(b.String())
|
||
v.AltScreen = true
|
||
v.WindowTitle = "🦞 Homarr Development"
|
||
return v
|
||
}
|
||
|
||
func RunDev() error {
|
||
return runApp(false)
|
||
}
|