model.go 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. package app
  2. import (
  3. "fmt"
  4. "time"
  5. "fbtop/internal/api"
  6. "fbtop/internal/detail"
  7. "fbtop/internal/diagview"
  8. "fbtop/internal/footer"
  9. "fbtop/internal/header"
  10. "fbtop/internal/shared"
  11. "fbtop/internal/tableview"
  12. tea "charm.land/bubbletea/v2"
  13. "charm.land/lipgloss/v2"
  14. )
  15. type tickMsg struct{}
  16. type errMsg struct{ err error }
  17. type Model struct {
  18. state shared.State
  19. url string
  20. interval time.Duration
  21. width int
  22. height int
  23. cursor int
  24. humanize bool
  25. showDetail bool
  26. showDiag bool
  27. sortCol shared.SortCol
  28. sortAsc bool
  29. filterKind shared.Kind
  30. err error
  31. }
  32. func New(url string, interval time.Duration, humanize bool, sortCol shared.SortCol, sortAsc bool, filterKind shared.Kind) Model {
  33. return Model{
  34. url: url,
  35. interval: interval,
  36. humanize: humanize,
  37. showDetail: true,
  38. showDiag: true,
  39. sortCol: sortCol,
  40. sortAsc: sortAsc,
  41. filterKind: filterKind,
  42. }
  43. }
  44. func (m Model) Init() tea.Cmd {
  45. return tea.Batch(m.fetch(), tick(m.interval))
  46. }
  47. func tick(d time.Duration) tea.Cmd {
  48. return func() tea.Msg {
  49. time.Sleep(d)
  50. return tickMsg{}
  51. }
  52. }
  53. func (m Model) fetch() tea.Cmd {
  54. prev := m.state.Comps
  55. return func() tea.Msg {
  56. s, err := api.Fetch(m.url)
  57. if err != nil {
  58. return errMsg{err}
  59. }
  60. s.PrevComps = prev
  61. return s
  62. }
  63. }
  64. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  65. switch msg := msg.(type) {
  66. case tea.WindowSizeMsg:
  67. m.width = msg.Width
  68. m.height = msg.Height
  69. case shared.State:
  70. m.state = msg
  71. m.err = nil
  72. return m, tick(m.interval)
  73. case errMsg:
  74. m.err = msg.err
  75. m.state.Connected = false
  76. return m, tick(m.interval)
  77. case tickMsg:
  78. return m, m.fetch()
  79. case tea.KeyPressMsg:
  80. switch msg.String() {
  81. case "q", "ctrl+c":
  82. return m, tea.Quit
  83. case "up", "k":
  84. if m.cursor > 0 {
  85. m.cursor--
  86. }
  87. case "down", "j":
  88. if m.cursor < len(m.filteredAndSorted())-1 {
  89. m.cursor++
  90. }
  91. case "tab": // cycle filter: all→input→filter→output
  92. m.filterKind = (m.filterKind + 1) % 4
  93. m.cursor = 0
  94. case "s":
  95. m.sortCol = m.sortCol.Next()
  96. case "S":
  97. m.sortAsc = !m.sortAsc
  98. case "h":
  99. m.humanize = !m.humanize
  100. case "enter":
  101. m.showDetail = !m.showDetail
  102. case "esc":
  103. m.showDetail = false
  104. case "d":
  105. m.showDiag = !m.showDiag
  106. }
  107. }
  108. return m, nil
  109. }
  110. func (m Model) View() tea.View {
  111. v := tea.NewView("")
  112. v.AltScreen = true
  113. if m.width == 0 {
  114. v.SetContent("Initializing...")
  115. return v
  116. }
  117. if m.err != nil {
  118. v.SetContent(fmt.Sprintf("Error: %v\n\nPress [q] to quit.", m.err))
  119. return v
  120. }
  121. showDetail := m.showDetail && len(m.filteredAndSorted()) > 0
  122. tableW := m.width
  123. rightW := 0
  124. if showDetail && m.width > 100 {
  125. rightW = m.width * 2 / 5
  126. tableW = m.width - rightW
  127. } else if showDetail {
  128. rightW = m.width / 2
  129. tableW = m.width - rightW
  130. }
  131. comps := m.filteredAndSorted()
  132. // Header
  133. headerLine := header.Render(m.state, m.url, m.width)
  134. // Left pane: table + diagnostics stacked vertically
  135. leftParts := []string{
  136. tableview.Render(tableview.Params{
  137. Comps: comps,
  138. PrevComps: m.state.PrevComps,
  139. Width: tableW,
  140. Cursor: m.cursor,
  141. Humanize: m.humanize,
  142. }),
  143. }
  144. if m.showDiag {
  145. diags := shared.AnalyzeDiagnostics(m.state, m.state.PrevComps)
  146. leftParts = append(leftParts, diagview.Render(diags, tableW))
  147. }
  148. leftContent := lipgloss.JoinVertical(lipgloss.Top, leftParts...)
  149. // Right pane: detail only
  150. rightContent := ""
  151. if showDetail {
  152. idx := m.cursor
  153. if idx >= len(comps) {
  154. idx = len(comps) - 1
  155. }
  156. prev := prevComp(comps[idx].ID, m.state.PrevComps)
  157. rightContent = detail.Render(comps[idx], prev, rightW)
  158. }
  159. // Main area
  160. var main string
  161. if rightContent != "" {
  162. main = lipgloss.JoinHorizontal(lipgloss.Top, leftContent, rightContent)
  163. } else {
  164. main = leftContent
  165. }
  166. // Footer
  167. foot := footer.Render(m.sortCol.String(), m.filterKind.String(), m.humanize, m.showDiag, m.width)
  168. v.SetContent(lipgloss.JoinVertical(lipgloss.Top, headerLine, main, foot))
  169. return v
  170. }
  171. func (m Model) filteredAndSorted() []shared.ComponentMetrics {
  172. comps := make([]shared.ComponentMetrics, 0, len(m.state.Comps))
  173. for _, c := range m.state.Comps {
  174. if m.filterKind == shared.KindAll || c.Kind == m.filterKind {
  175. comps = append(comps, c)
  176. }
  177. }
  178. shared.SortBy(comps, m.sortCol, m.sortAsc)
  179. return comps
  180. }
  181. func prevComp(id string, prevComps []shared.ComponentMetrics) shared.ComponentMetrics {
  182. for _, c := range prevComps {
  183. if c.ID == id {
  184. return c
  185. }
  186. }
  187. return shared.ComponentMetrics{}
  188. }