Files
homarr/tools/homarr-dev/internal/tui/dev.go
T

1159 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}