diff --git a/tools/homarr-dev/internal/tui/app.go b/tools/homarr-dev/internal/tui/app.go new file mode 100644 index 000000000..d7348205d --- /dev/null +++ b/tools/homarr-dev/internal/tui/app.go @@ -0,0 +1,122 @@ +package tui + +import ( + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" +) + +type appModel struct { + dev prsModel + dashboard dashModel + showDashboard bool + width int + height int +} + +func newAppModel(showDashboard bool) appModel { + return appModel{dev: newPRsModel(), dashboard: newDashModel(), showDashboard: showDashboard} +} + +func (m appModel) Init() tea.Cmd { + if m.showDashboard { + return m.dashboard.Init() + } + return m.dev.Init() +} + +func (m appModel) canSwitch() bool { + if m.showDashboard { + return !m.dashboard.filtering + } + return !m.dev.filtering && !m.dev.pulling && !m.dev.rebuilding +} + +func (m appModel) switchScreen() (tea.Model, tea.Cmd) { + m.showDashboard = !m.showDashboard + commands := make([]tea.Cmd, 0, 2) + if m.width > 0 && m.height > 0 { + resize := tea.WindowSizeMsg{Width: m.width, Height: m.height} + if m.showDashboard { + updated, command := m.dashboard.Update(resize) + m.dashboard = updated.(dashModel) + commands = append(commands, command) + } else { + updated, command := m.dev.Update(resize) + m.dev = updated.(prsModel) + commands = append(commands, command) + } + } + if m.showDashboard { + commands = append(commands, m.dashboard.Init()) + } else { + commands = append(commands, m.dev.Init()) + } + return m, tea.Batch(commands...) +} + +func (m appModel) updateDev(msg tea.Msg) (appModel, tea.Cmd) { + updated, command := m.dev.Update(msg) + m.dev = updated.(prsModel) + return m, command +} + +func (m appModel) updateDashboard(msg tea.Msg) (appModel, tea.Cmd) { + updated, command := m.dashboard.Update(msg) + m.dashboard = updated.(dashModel) + return m, command +} + +func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if window, ok := msg.(tea.WindowSizeMsg); ok { + m.width = window.Width + m.height = window.Height + m, devCommand := m.updateDev(msg) + m, dashboardCommand := m.updateDashboard(msg) + return m, tea.Batch(devCommand, dashboardCommand) + } + if key, ok := msg.(tea.KeyPressMsg); ok { + if key.String() == "d" && m.canSwitch() { + return m.switchScreen() + } + if m.showDashboard { + return m.updateDashboard(msg) + } + return m.updateDev(msg) + } + + switch msg.(type) { + case prsLoadedMsg, imageCheckedMsg, prActionMsg, rebuildDoneMsg, runningRefreshedMsg, pullReadyMsg, pullEvent, spinner.TickMsg: + return m.updateDev(msg) + case tickMsg, actionMsg, rowsMsg: + if m.showDashboard { + return m.updateDashboard(msg) + } + return m, nil + case prLogTickMsg: + if !m.showDashboard { + return m.updateDev(msg) + } + return m, nil + case logsMsg: + m, devCommand := m.updateDev(msg) + m, dashboardCommand := m.updateDashboard(msg) + return m, tea.Batch(devCommand, dashboardCommand) + } + if m.showDashboard { + return m.updateDashboard(msg) + } + return m.updateDev(msg) +} + +func (m appModel) View() tea.View { + if m.showDashboard { + return m.dashboard.View() + } + return m.dev.View() +} + +func runApp(showDashboard bool) error { + program := tea.NewProgram(newAppModel(showDashboard)) + _, err := program.Run() + return err +} diff --git a/tools/homarr-dev/internal/tui/app_test.go b/tools/homarr-dev/internal/tui/app_test.go new file mode 100644 index 000000000..fe6f34199 --- /dev/null +++ b/tools/homarr-dev/internal/tui/app_test.go @@ -0,0 +1,37 @@ +package tui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh" +) + +func TestAppTogglePreservesDevelopmentState(t *testing.T) { + m := newAppModel(false) + m.dev.rows = []prRow{{kind: "remote", pr: gh.PR{Number: 6502}}} + + updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"})) + dashboard := updated.(appModel) + if !dashboard.showDashboard { + t.Fatal("d did not open the instance dashboard") + } + updated, _ = dashboard.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"})) + development := updated.(appModel) + if development.showDashboard || len(development.dev.rows) != 1 || development.dev.rows[0].pr.Number != 6502 { + t.Fatalf("development state was not preserved: %#v", development.dev.rows) + } +} + +func TestAppToggleDoesNotInterceptFilterInput(t *testing.T) { + m := newAppModel(false) + m.dev.filtering = true + m.dev.filter.Focus() + + updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"})) + got := updated.(appModel) + if got.showDashboard || got.dev.filter.Value() != "d" { + t.Fatalf("filter input was intercepted: dashboard=%v filter=%q", got.showDashboard, got.dev.filter.Value()) + } +} diff --git a/tools/homarr-dev/internal/tui/dashboard.go b/tools/homarr-dev/internal/tui/dashboard.go index a709d87dd..5cfe2d496 100644 --- a/tools/homarr-dev/internal/tui/dashboard.go +++ b/tools/homarr-dev/internal/tui/dashboard.go @@ -402,7 +402,7 @@ func (m dashModel) View() tea.View { } b.WriteString("\n" + statusStyle.Render(m.status)) } - b.WriteString("\n\n" + helpStyle.Render("/ filter · ↑/↓ select · l logs · f follow · pgup/pgdn scroll · s stop · R restart · x remove · o open · c copy URL · r refresh · q quit")) + b.WriteString("\n\n" + helpStyle.Render("/ filter · ↑/↓ select · l logs · f follow · pgup/pgdn scroll · s stop · R restart · x remove · o open · c copy URL · r refresh · d development · q quit")) b.WriteString("\n" + helpStyle.Render(runningLegend+" · "+stoppedLegend)) v := tea.NewView(b.String()) v.AltScreen = true @@ -411,7 +411,5 @@ func (m dashModel) View() tea.View { } func RunDashboard() error { - p := tea.NewProgram(newDashModel()) - _, err := p.Run() - return err + return runApp(true) } diff --git a/tools/homarr-dev/internal/tui/dev.go b/tools/homarr-dev/internal/tui/dev.go index 5d3ed5b28..2b78488a3 100644 --- a/tools/homarr-dev/internal/tui/dev.go +++ b/tools/homarr-dev/internal/tui/dev.go @@ -1049,7 +1049,7 @@ func (m prsModel) View() tea.View { b.WriteString("\n\n" + helpStyle.Render("q/esc cancel rebuild")) } else 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 · q quit")) + 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")) } 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")) @@ -1060,7 +1060,5 @@ func (m prsModel) View() tea.View { } func RunDev() error { - p := tea.NewProgram(newPRsModel()) - _, err := p.Run() - return err + return runApp(false) }