package tui import ( "fmt" "strings" "time" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/progress" "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/table" "charm.land/bubbles/v2/textinput" 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/logs" "github.com/homarr-labs/homarr/tools/homarr-dev/internal/task" "github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui" ) type screen int const ( screenDev screen = iota screenInstances ) // mode is the interaction state. Every key press is routed by mode first, which // is what makes chords like `m` then `i` unambiguous: the same physical key // means different things depending on which mode armed it. type mode int const ( modeNormal mode = iota modeFilter modeManage modeConfirm modeImageSelect modeHelp modeTasks ) type imageChoice int const ( imageChoiceLocal imageChoice = iota imageChoiceRemote ) type imageSelection struct { rowKey string choice imageChoice } type statusLevel int const ( levelInfo statusLevel = iota levelOK levelWarn levelError ) type ciCheckState struct { loading bool checks []gh.Check err error updated time.Time } // Model is the whole application. It owns the data, the two screens, the // sidebar, and the background task manager; the panes are rendering concerns // rather than separate Bubble Tea models, which keeps the message routing flat // and means any key can act on any part of the state. type Model struct { keys ui.KeyMap tasks *task.Manager logs *logs.Registry screen screen mode mode prs []gh.PR images []docker.Image containers []docker.Container volumes []docker.Volume tags map[string]bool tagsKnown bool devRows []devRow devFiltered []devRow instRows []instanceRow instFiltered []instanceRow devTable table.Model instTable table.Model filter textinput.Model helpView help.Model spinner spinner.Model progress progress.Model sidebar sidebar ciChecks map[int]ciCheckState taskList []task.Snapshot taskStates map[int]task.State taskCursor int focusTask int confirm confirmation imageSelection imageSelection includeBots bool demo bool loading bool animating bool spinning bool generation int status string statusLevel statusLevel width int height int bodyHeight int sidebarCols int sidebarStacked bool } // New builds the application model. showInstances picks the starting screen. func New(showInstances bool) Model { styles := table.DefaultStyles() styles.Header = styles.Header.Bold(true).Foreground(ui.Muted) styles.Selected = ui.Selected devTable := table.New(table.WithColumns(devColumns(110)), table.WithFocused(true), table.WithHeight(16), table.WithWidth(110)) devTable.SetStyles(styles) instTable := table.New(table.WithColumns(instanceColumns(110)), table.WithFocused(true), table.WithHeight(16), table.WithWidth(110)) instTable.SetStyles(styles) filter := textinput.New() filter.Prompt = "/ " filter.Placeholder = "filter by PR, title, author, branch, image, state, port…" filter.SetWidth(60) filterStyles := textinput.DefaultDarkStyles() filterStyles.Focused.Prompt = filterStyles.Focused.Prompt.Foreground(ui.Accent).Bold(true) filterStyles.Focused.Text = filterStyles.Focused.Text.Foreground(ui.Bright) filter.SetStyles(filterStyles) helpModel := help.New() helpModel.Styles = help.DefaultStyles(true) spin := spinner.New(spinner.WithSpinner(spinner.Dot), spinner.WithStyle(lipgloss.NewStyle().Foreground(ui.Accent))) start := screenDev if showInstances { start = screenInstances } return Model{ keys: ui.DefaultKeyMap(), tasks: task.NewManager(), logs: logs.NewRegistry(), screen: start, devTable: devTable, instTable: instTable, filter: filter, helpView: helpModel, spinner: spin, progress: progress.New(progress.WithWidth(24), progress.WithoutPercentage(), progress.WithColors(ui.Accent)), sidebar: newSidebar(), ciChecks: make(map[int]ciCheckState), taskStates: make(map[int]task.State), loading: true, // Init cannot persist state because it takes the model by value, so the // clocks it arms are recorded as running here instead. spinning: true, animating: true, status: "loading pull requests, images and instances…", width: 110, height: 34, } } func (m Model) Init() tea.Cmd { return tea.Batch( loadAll(m.generation, m.includeBots, false), waitForTasks(m.tasks), waitForLogs(m.logs), tickRefresh(), m.spinner.Tick, tickFrame(), ) } // animate arms the animation clocks that are needed and not already ticking. // Both clocks stop themselves once nothing is in flight, so an idle CLI does no // work at all, and because arming is centralised a handler cannot start a second // competing tick chain. It mutates the receiver, so callers must hoist the call // out of a `return m, m.animate()` — the order of evaluation there is not // specified, and getting it wrong is exactly how a duplicate chain appears. func (m *Model) animate() tea.Cmd { if !m.busy() { return nil } commands := make([]tea.Cmd, 0, 2) if !m.spinning { m.spinning = true commands = append(commands, m.spinner.Tick) } if !m.animating { m.animating = true commands = append(commands, tickFrame()) } return tea.Batch(commands...) } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.filter.SetWidth(clampInt(msg.Width-6, 20, 72)) m.helpView.SetWidth(msg.Width) m.relayout() return m, nil case loadedMsg: if msg.generation != m.generation { return m, nil } return m.applyLoaded(msg) case containersMsg: if msg.generation != m.generation { return m, nil } sizesByName := make(map[string]string, len(m.volumes)) for _, v := range m.volumes { if v.Size != "" { sizesByName[v.Name] = v.Size } } for i := range msg.volumes { if msg.volumes[i].Size == "" { msg.volumes[i].Size = sizesByName[msg.volumes[i].Name] } } m.containers, m.volumes = msg.containers, msg.volumes m.rebuildRows() m.relayout() cmd := m.refreshSidebar() return m, tea.Batch(cmd, m.syncLogStreams()) case tasksChangedMsg: return m.applyTasks() case logsChangedMsg: cmd := m.refreshSidebar() return m, tea.Batch(cmd, waitForLogs(m.logs)) case ciChecksMsg: m.ciChecks[msg.pr] = ciCheckState{ loading: false, checks: msg.checks, err: msg.err, updated: time.Now(), } cmd := m.refreshSidebar() for _, c := range msg.checks { if c.IsPending() { pollCmd := tea.Tick(6*time.Second, func(t time.Time) tea.Msg { return pollCIChecksMsg{pr: msg.pr} }) return m, tea.Batch(cmd, pollCmd) } } return m, cmd case pollCIChecksMsg: if m.sidebar.source == sourceCI && m.selectedPRNumber() == msg.pr { return m, fetchCIChecks(msg.pr, true) } return m, nil case refreshMsg: return m, tea.Batch(tickRefresh(), loadContainers(m.generation)) case frameMsg: cmd := m.refreshSidebar() if !m.busy() { m.animating = false return m, cmd } return m, tea.Batch(cmd, tickFrame()) case spinner.TickMsg: if !m.busy() { m.spinning = false return m, nil } var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd case statusMsg: m.status, m.statusLevel = msg.text, msg.level return m, nil case progress.FrameMsg: var cmd tea.Cmd m.progress, cmd = m.progress.Update(msg) return m, cmd case tea.KeyPressMsg: return m.handleKey(msg) } return m, nil } func (m Model) busy() bool { return m.loading || m.tasks.Running() > 0 } // applyLoaded folds a full refresh into the model, preserving the selected row // so a background refresh never moves the cursor under the user's hands. func (m Model) applyLoaded(msg loadedMsg) (Model, tea.Cmd) { m.loading = false if msg.prErr == nil { m.prs = msg.prs } if msg.imageErr == nil { m.images = msg.images } if msg.tagErr == nil && msg.tags != nil { m.tags, m.tagsKnown = msg.tags, true } m.containers, m.volumes = msg.containers, msg.volumes switch { case msg.prErr != nil && msg.imageErr != nil: m.status, m.statusLevel = "cannot reach GitHub or Docker: "+msg.prErr.Error(), levelError case msg.prErr != nil: m.status, m.statusLevel = "pull requests unavailable: "+msg.prErr.Error(), levelWarn case msg.imageErr != nil: m.status, m.statusLevel = "local images unavailable: "+msg.imageErr.Error(), levelWarn case msg.tagErr != nil: m.status, m.statusLevel = "GHCR tag listing unavailable: "+msg.tagErr.Error(), levelWarn } m.rebuildRows() if msg.prErr == nil && msg.imageErr == nil && msg.tagErr == nil { m.status, m.statusLevel = m.summary(), levelInfo } m.relayout() cmd := m.refreshSidebar() return m, tea.Batch(cmd, m.syncLogStreams()) } func (m Model) summary() string { remote, local, running := 0, 0, 0 for _, row := range m.devRows { if row.kind == rowLocal { local++ } else { remote++ if row.hasCurrentLocalImage() { local++ } } if row.running { running++ } } return fmt.Sprintf("%d pull requests · %d local images · %d running", remote, local, running) } // applyTasks reacts to background progress. Finished jobs trigger a data // refresh so the table reflects the new image or container without the user // having to press r. func (m Model) applyTasks() (Model, tea.Cmd) { m.taskList = m.tasks.Snapshots() commands := []tea.Cmd{waitForTasks(m.tasks)} for _, snapshot := range m.taskList { previous, seen := m.taskStates[snapshot.ID] if seen && previous == snapshot.State { continue } m.taskStates[snapshot.ID] = snapshot.State if !snapshot.State.Done() { continue } switch snapshot.State { case task.StateSucceeded: m.status, m.statusLevel = snapshot.Title+" finished", levelOK if m.sidebar.source == sourceTask && (m.focusTask == 0 || m.focusTask == snapshot.ID) { m.sidebar.source = sourceLogs m.focusTask = 0 } case task.StateFailed: m.status, m.statusLevel = snapshot.Title+" failed: "+errorText(snapshot.Err), levelError case task.StateCanceled: m.status, m.statusLevel = snapshot.Title+" canceled", levelWarn if m.sidebar.source == sourceTask && (m.focusTask == 0 || m.focusTask == snapshot.ID) { m.sidebar.source = sourceLogs m.focusTask = 0 } } m.generation++ commands = append(commands, loadAll(m.generation, m.includeBots, true)) } if m.tasks.Running() == 0 && m.sidebar.source == sourceTask && m.focusTask == 0 { m.sidebar.source = sourceLogs } if focused, found := m.focusedTask(); found { if command := m.progress.SetPercent(focused.Percent); command != nil { commands = append(commands, command) } } if clocks := m.animate(); clocks != nil { commands = append(commands, clocks) } m.relayout() if cmd := m.refreshSidebar(); cmd != nil { commands = append(commands, cmd) } return m, tea.Batch(commands...) } func errorText(err error) string { if err == nil { return "unknown error" } return err.Error() } // focusedTask is the job the tray and sidebar follow: the one the user last // started, falling back to the newest running job. func (m Model) focusedTask() (task.Snapshot, bool) { if m.focusTask > 0 { if snapshot, found := m.tasks.Snapshot(m.focusTask); found { return snapshot, true } } for index := len(m.taskList) - 1; index >= 0; index-- { if m.taskList[index].State == task.StateRunning { return m.taskList[index], true } } if len(m.taskList) > 0 { return m.taskList[len(m.taskList)-1], true } return task.Snapshot{}, false } // rebuildRows recomputes both screens from the current data, keeping the // selection anchored to the same logical row. func (m *Model) rebuildRows() { devKey := m.selectedDevKey() instKey := m.selectedInstanceName() m.devRows = applyContainers(buildDevRows(m.prs, m.images, m.tags, m.tagsKnown), m.containers) m.instRows = buildInstanceRows(m.containers, m.volumes) m.applyFilter() m.restoreDevSelection(devKey) m.restoreInstanceSelection(instKey) } func (m *Model) applyFilter() { query := strings.ToLower(strings.TrimSpace(m.filter.Value())) m.devFiltered = m.devFiltered[:0] for _, row := range m.devRows { if query == "" || strings.Contains(row.searchTerms(), query) { m.devFiltered = append(m.devFiltered, row) } } devCols := devColumns(m.tableWidth()) m.devTable.SetColumns(devCols) m.devTable.SetRows(devTableRows(m.devFiltered, devCols)) clampCursor(&m.devTable, len(m.devFiltered)) m.instFiltered = m.instFiltered[:0] for _, row := range m.instRows { if query == "" || strings.Contains(row.searchTerms(), query) { m.instFiltered = append(m.instFiltered, row) } } instCols := instanceColumns(m.tableWidth()) m.instTable.SetColumns(instCols) m.instTable.SetRows(instanceTableRows(m.instFiltered, instCols)) clampCursor(&m.instTable, len(m.instFiltered)) } // clampCursor keeps the selection inside the row set. SetCursor clamps against // the rows the table currently holds, so calling it while the table is empty // parks the cursor at -1; this restores it as soon as rows exist again. func clampCursor(model *table.Model, length int) { if length == 0 { return } if cursor := model.Cursor(); cursor < 0 || cursor >= length { model.SetCursor(clampInt(cursor, 0, length-1)) } } func (m Model) selectedDevKey() string { if row, found := m.selectedDev(); found { return row.key() } return "" } func (m *Model) restoreDevSelection(key string) { if key == "" { return } for index, row := range m.devFiltered { if row.key() == key { m.devTable.SetCursor(index) return } } } func (m Model) selectedInstanceName() string { if row, found := m.selectedInstance(); found { return row.container.Name } return "" } func (m *Model) restoreInstanceSelection(name string) { if name == "" { return } for index, row := range m.instFiltered { if row.container.Name == name { m.instTable.SetCursor(index) return } } } func (m Model) selectedDev() (devRow, bool) { index := m.devTable.Cursor() if index < 0 || index >= len(m.devFiltered) { return devRow{}, false } return m.devFiltered[index], true } func (m Model) selectedInstance() (instanceRow, bool) { index := m.instTable.Cursor() if index < 0 || index >= len(m.instFiltered) { return instanceRow{}, false } return m.instFiltered[index], true } // The sidebar splits the screen side by side when there is room, and stacks // under the table when there is not. Hiding it outright on a narrow terminal // would make live logs unavailable exactly where they are most needed. const sidebarSplitWidth = 96 func (m Model) tableWidth() int { if m.sidebarCols > 0 { return max(m.width-m.sidebarCols-1, 40) } return max(m.width, 40) } // mainHeight is the number of lines the table or an overlay may occupy. func (m Model) mainHeight() int { if m.sidebarStacked { return max(m.bodyHeight-m.sidebar.height, 4) } return m.bodyHeight } func (m Model) detailVisible() bool { if m.screen == screenInstances { _, found := m.selectedInstance() return found } _, found := m.selectedDev() return found } // relayout recomputes every component's size from the terminal dimensions. The // reserved count has to match what View actually emits line for line, or the // bottom of the interface is pushed off screen. func (m *Model) relayout() { reserved := 2 // header + blank if m.mode == modeFilter || m.filter.Value() != "" { reserved += 2 } if m.detailVisible() { reserved += 3 // blank + title + meta } if m.tasks.Running() > 0 { reserved += 1 } switch m.mode { case modeManage: reserved += 2 case modeConfirm: reserved += 3 case modeImageSelect: reserved += 5 } if m.status != "" { reserved += 2 } reserved += 3 // blank + two footer lines m.bodyHeight = max(m.height-reserved, 6) m.sidebarCols = 0 m.sidebarStacked = false if m.sidebarRelevant() { if m.width >= sidebarSplitWidth { m.sidebarCols = clampInt(m.width*2/5, 34, 72) m.sidebar.resize(m.sidebarCols, m.bodyHeight) } else { m.sidebarStacked = true m.sidebar.resize(m.width, clampInt(m.bodyHeight/2, 6, 14)) } } // A table renders a header line plus one line per row, so its height is // one less than the space it is given. tableHeight := max(m.mainHeight()-1, 3) m.devTable.SetWidth(m.tableWidth()) m.devTable.SetHeight(tableHeight) m.instTable.SetWidth(m.tableWidth()) m.instTable.SetHeight(tableHeight) m.applyFilter() m.progress.SetWidth(clampInt(m.width/4, 12, 32)) } // syncLogStreams drops followers whose container no longer exists. Attaching is // deliberately left to refreshSidebar: following every container eagerly would // thrash against the stream cap on a machine running more instances than the // cap allows, killing and restarting `docker logs` every refresh. func (m Model) syncLogStreams() tea.Cmd { live := make([]string, 0, len(m.containers)) for _, container := range m.containers { live = append(live, container.Name) } m.logs.Retain(live) return nil } // refreshSidebar recomputes panel content from whichever source is selected. func (m *Model) refreshSidebar() tea.Cmd { if !m.sidebarRelevant() { return nil } if m.sidebar.source == sourceTask { snapshot, found := m.focusedTask() if !found { m.sidebar.title, m.sidebar.subtitle = "tasks", "no background work" m.sidebar.setContent(ui.Help.Render("Press R to build locally or p to pull a pull-request image.")) return nil } title, subtitle, content := renderTaskSnapshot(snapshot, m.sidebar.textWidth()) m.sidebar.title, m.sidebar.subtitle = title, subtitle m.sidebar.setContent(content) return nil } if m.sidebar.source == sourceCI { pr := m.selectedPRNumber() state, found := m.ciChecks[pr] var cmd tea.Cmd if pr > 0 && (!found || (!state.loading && time.Since(state.updated) > 30*time.Second)) { m.ciChecks[pr] = ciCheckState{loading: true, checks: state.checks, err: state.err, updated: state.updated} cmd = fetchCIChecks(pr, false) } title, subtitle, content := renderCISnapshot(pr, state.checks, state.loading, state.err, m.sidebar.textWidth()) m.sidebar.title, m.sidebar.subtitle = title, subtitle m.sidebar.setContent(content) return cmd } name := m.sidebar.pinned if name == "" { name = m.logTarget() } if name == "" { return nil } // Attaching here is what starts the stream, and it is a no-op once one // exists. A stopped container is still worth following: `docker logs` // replays why it died and then ends the stream. m.logs.Attach(name) snapshot, attached := m.logs.Snapshot(name) title, subtitle, content := renderLogSnapshot(name, snapshot, attached) if m.sidebar.pinned != "" { subtitle = ui.IconPin + " pinned · " + subtitle } m.sidebar.title, m.sidebar.subtitle = title, subtitle m.sidebar.setContent(content) return nil } // logTarget is the container whose logs the sidebar shows by default. func (m Model) logTarget() string { if m.screen == screenInstances { if row, found := m.selectedInstance(); found { return row.container.Name } return "" } if row, found := m.selectedDev(); found { if row.state != "" || row.running { return row.containerName() } } return "" } func (m Model) selectedPRNumber() int { if m.screen == screenInstances { if row, found := m.selectedInstance(); found { return row.prNumber() } return 0 } if row, found := m.selectedDev(); found { if row.kind == rowRemote { return row.pr.Number } if row.local.PRNumber > 0 { return row.local.PRNumber } } return 0 } func (m Model) containerExists(name string) bool { if name == "" { return false } for _, c := range m.containers { if c.Name == name { return true } } return false } // sidebarRelevant determines whether the right-hand panel has meaningful // content to display (active tasks, CI checks on a PR, pinned logs, or an existing container). func (m Model) sidebarRelevant() bool { if !m.sidebar.visible { return false } if m.sidebar.source == sourceTask { _, found := m.focusedTask() return found || m.tasks.Running() > 0 } if m.sidebar.source == sourceCI { return m.selectedPRNumber() > 0 } if m.sidebar.pinned != "" && m.containerExists(m.sidebar.pinned) { return true } return m.logTarget() != "" } func formatDuration(d time.Duration) string { switch { case d < time.Minute: return fmt.Sprintf("%.0fs", d.Seconds()) case d < time.Hour: return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60) default: return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60) } } func shortHelp(bindings []key.Binding, model help.Model) string { return model.ShortHelpView(bindings) }