From db6aa0b26ec2c7803a92ea26ffde0f063a51491e Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 2 Aug 2026 08:19:29 +0200 Subject: [PATCH] feat(dev): prefer current local PR builds --- tools/homarr-dev/internal/tui/app.go | 2 +- tools/homarr-dev/internal/tui/app_test.go | 10 ++ tools/homarr-dev/internal/tui/dev.go | 112 ++++++++++++++++++---- tools/homarr-dev/internal/tui/dev_test.go | 53 ++++++++++ 4 files changed, 155 insertions(+), 22 deletions(-) diff --git a/tools/homarr-dev/internal/tui/app.go b/tools/homarr-dev/internal/tui/app.go index d7348205d..1af605250 100644 --- a/tools/homarr-dev/internal/tui/app.go +++ b/tools/homarr-dev/internal/tui/app.go @@ -28,7 +28,7 @@ func (m appModel) canSwitch() bool { if m.showDashboard { return !m.dashboard.filtering } - return !m.dev.filtering && !m.dev.pulling && !m.dev.rebuilding + return !m.dev.filtering && !m.dev.pulling } func (m appModel) switchScreen() (tea.Model, tea.Cmd) { diff --git a/tools/homarr-dev/internal/tui/app_test.go b/tools/homarr-dev/internal/tui/app_test.go index fe6f34199..1112ebb29 100644 --- a/tools/homarr-dev/internal/tui/app_test.go +++ b/tools/homarr-dev/internal/tui/app_test.go @@ -35,3 +35,13 @@ func TestAppToggleDoesNotInterceptFilterInput(t *testing.T) { t.Fatalf("filter input was intercepted: dashboard=%v filter=%q", got.showDashboard, got.dev.filter.Value()) } } + +func TestAppCanOpenInstancesDuringBuild(t *testing.T) { + m := newAppModel(false) + m.dev.rebuilding = true + + updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"})) + if !updated.(appModel).showDashboard { + t.Fatal("background build blocked dashboard navigation") + } +} diff --git a/tools/homarr-dev/internal/tui/dev.go b/tools/homarr-dev/internal/tui/dev.go index 2b78488a3..763ca9240 100644 --- a/tools/homarr-dev/internal/tui/dev.go +++ b/tools/homarr-dev/internal/tui/dev.go @@ -35,6 +35,7 @@ const ( imgNo = "…" imgCheck = "?" imgError = "!" + imgLocal = "◆" runMark = "▶" idleMark = " " ) @@ -46,6 +47,7 @@ var ( 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 { @@ -57,6 +59,10 @@ type prRow struct { 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 @@ -264,6 +270,9 @@ func buildTableRows(rows []prRow) []table.Row { case "error": img = imgError } + if r.hasCurrentLocalImage() { + img = imgLocal + } source := "remote" ref := fmt.Sprint(r.pr.Number) title := r.pr.Title @@ -298,6 +307,38 @@ func buildTableRows(rows []prRow) []table.Row { 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 @@ -438,6 +479,18 @@ func localBuildForRow(row prRow) (docker.Image, string, string) { 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 { @@ -577,21 +630,9 @@ func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } - m.rows = make([]prRow, 0, len(msg.prs)+len(msg.images)) + m.rows, m.imageQueue = developmentRows(msg.prs, msg.images, imageStates) m.imageGen++ - m.imageQueue = m.imageQueue[:0] m.imageActive = 0 - for _, pr := range msg.prs { - imageState := imageStates[pr.Number] - if imageState == "" { - imageState = "checking" - m.imageQueue = append(m.imageQueue, pr.Number) - } - m.rows = append(m.rows, prRow{kind: "remote", pr: pr, imageState: imageState}) - } - for _, image := range msg.images { - m.rows = append(m.rows, prRow{kind: "local", local: image, imageState: "yes"}) - } m.applyFilter() return m, tea.Batch(m.startImageChecks(), refreshRunning(m.rows, m.imageGen)) case imageCheckedMsg: @@ -641,7 +682,7 @@ func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.status = action + " " + msg.image.Reference() } - return m, tea.Batch(loadDev(m.includeBots, false), m.spinner.Tick) + 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 @@ -715,8 +756,8 @@ func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.rebuildCancel() } m.status = "canceling rebuild…" + return m, nil } - return m, nil } if m.pulling { switch msg.String() { @@ -792,6 +833,20 @@ func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } 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 @@ -858,6 +913,10 @@ func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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" @@ -955,7 +1014,14 @@ func (m prsModel) selectedDetailView() string { if branch == "" { branch = "unknown branch" } - meta := "@" + row.pr.Author + " · " + branch + " · CI " + coloredCI(row.pr.CIState) + " · Image " + coloredImage(row.imageState) + 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) } @@ -974,7 +1040,7 @@ func truncateText(value string, length int) string { func (m prsModel) View() tea.View { var b strings.Builder header := titleStyle.Render("🦞 homarr development") - if m.loading { + if m.loading || m.rebuilding { header += " " + m.spinner.View() } running := 0 @@ -985,6 +1051,9 @@ func (m prsModel) View() tea.View { local++ } else { remote++ + if row.hasCurrentLocalImage() { + local++ + } } if row.running { running++ @@ -1045,14 +1114,15 @@ func (m prsModel) View() tea.View { if m.includeBots { botState = "bots shown" } - if m.rebuilding { - b.WriteString("\n\n" + helpStyle.Render("q/esc cancel rebuild")) - } else if !m.pulling { + 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: "+legendImageYes+" available "+legendImageNo+" not built")) + 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" diff --git a/tools/homarr-dev/internal/tui/dev_test.go b/tools/homarr-dev/internal/tui/dev_test.go index 647b8ca5e..2aa3252d6 100644 --- a/tools/homarr-dev/internal/tui/dev_test.go +++ b/tools/homarr-dev/internal/tui/dev_test.go @@ -77,6 +77,43 @@ func TestPRLoadedReusesImageChecksUnlessRefreshIsForced(t *testing.T) { } } +func TestDevelopmentRowsFoldCurrentLocalPRImage(t *testing.T) { + prs := []gh.PR{{Number: 6502, HeadSHA: "current"}} + images := []docker.Image{ + {Tag: "pr-6502", PRNumber: 6502, Revision: "current"}, + {Tag: "pr-6502-stale", PRNumber: 6502, Revision: "stale"}, + } + + rows, queue := developmentRows(prs, images, nil) + if len(rows) != 2 || !rows[0].hasCurrentLocalImage() || rows[0].local.Tag != "pr-6502" { + t.Fatalf("current image was not folded into PR row: %#v", rows) + } + if rows[1].kind != "local" || rows[1].local.Tag != "pr-6502-stale" { + t.Fatalf("stale image should remain separate: %#v", rows[1]) + } + if got := buildTableRows(rows)[0][6]; got != imgLocal { + t.Fatalf("image marker = %q, want %q", got, imgLocal) + } + if len(queue) != 0 { + t.Fatalf("current local image queued unnecessary registry checks: %v", queue) + } +} + +func TestLocalPRPlanUsesPRContainerIdentity(t *testing.T) { + row := prRow{ + kind: "remote", + pr: gh.PR{Number: 6502, HeadSHA: "abcdef1234567890"}, + local: docker.Image{Tag: "pr-6502", PRNumber: 6502, Revision: "abcdef1234567890"}, + } + plan, err := localPRPlan(row, false, func(int) int { return 7575 }) + if err != nil { + t.Fatal(err) + } + if plan.Image != "homarr:pr-6502" || plan.Name != "homarr_pr_6502" || plan.Volume != "homarr_pr_6502_data" || plan.Pull || plan.PRNumber != 6502 { + t.Fatalf("unexpected local PR plan: %#v", plan) + } +} + func TestRemotePRBuildUsesTemporaryPRWorkflow(t *testing.T) { image, action, status := localBuildForRow(prRow{kind: "remote", pr: gh.PR{Number: 6441}}) if image.Tag != "pr-6441" || image.PRNumber != 6441 || action != "built" || !strings.Contains(status, "building PR #6441 locally") { @@ -148,6 +185,22 @@ func TestRebuildCanBeCanceled(t *testing.T) { } } +func TestNavigationRemainsAvailableDuringBuild(t *testing.T) { + m := newPRsModel() + m.rebuilding = true + m.rows = []prRow{ + {kind: "remote", pr: gh.PR{Number: 1}}, + {kind: "remote", pr: gh.PR{Number: 2}}, + } + m.table.SetRows(buildTableRows(m.rows)) + + updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown})) + selected := updated.(prsModel).table.SelectedRow() + if len(selected) < 3 || selected[2] != "2" { + t.Fatalf("build blocked navigation: %v", selected) + } +} + func TestCompletedPullWithoutPlanDoesNotPanic(t *testing.T) { m := newPRsModel() m.pulling = true