table.go 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. package logs
  2. import (
  3. "context"
  4. "log/slog"
  5. "github.com/charmbracelet/bubbles/key"
  6. "github.com/charmbracelet/bubbles/table"
  7. tea "github.com/charmbracelet/bubbletea"
  8. "github.com/sst/opencode/internal/app"
  9. "github.com/sst/opencode/internal/logging"
  10. "github.com/sst/opencode/internal/pubsub"
  11. "github.com/sst/opencode/internal/tui/layout"
  12. "github.com/sst/opencode/internal/tui/state"
  13. "github.com/sst/opencode/internal/tui/theme"
  14. )
  15. type TableComponent interface {
  16. tea.Model
  17. layout.Sizeable
  18. layout.Bindings
  19. }
  20. type tableCmp struct {
  21. app *app.App
  22. table table.Model
  23. focused bool
  24. logs []logging.Log
  25. selectedLogID string
  26. }
  27. type selectedLogMsg logging.Log
  28. type LogsLoadedMsg struct {
  29. logs []logging.Log
  30. }
  31. func (i *tableCmp) Init() tea.Cmd {
  32. return i.fetchLogs()
  33. }
  34. func (i *tableCmp) fetchLogs() tea.Cmd {
  35. return func() tea.Msg {
  36. ctx := context.Background()
  37. var logs []logging.Log
  38. var err error
  39. // Limit the number of logs to improve performance
  40. const logLimit = 100
  41. if i.app.CurrentSession.ID == "" {
  42. logs, err = i.app.Logs.ListAll(ctx, logLimit)
  43. } else {
  44. logs, err = i.app.Logs.ListBySession(ctx, i.app.CurrentSession.ID)
  45. // Trim logs if there are too many
  46. if err == nil && len(logs) > logLimit {
  47. logs = logs[len(logs)-logLimit:]
  48. }
  49. }
  50. if err != nil {
  51. slog.Error("Failed to fetch logs", "error", err)
  52. return nil
  53. }
  54. return LogsLoadedMsg{logs: logs}
  55. }
  56. }
  57. func (i *tableCmp) updateRows() tea.Cmd {
  58. return func() tea.Msg {
  59. rows := make([]table.Row, 0, len(i.logs))
  60. for _, log := range i.logs {
  61. timeStr := log.Timestamp.Local().Format("15:04:05")
  62. // Include ID as hidden first column for selection
  63. row := table.Row{
  64. log.ID,
  65. timeStr,
  66. log.Level,
  67. log.Message,
  68. }
  69. rows = append(rows, row)
  70. }
  71. i.table.SetRows(rows)
  72. return nil
  73. }
  74. }
  75. func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  76. var cmds []tea.Cmd
  77. switch msg := msg.(type) {
  78. case LogsLoadedMsg:
  79. i.logs = msg.logs
  80. return i, i.updateRows()
  81. case state.SessionSelectedMsg:
  82. return i, i.fetchLogs()
  83. case pubsub.Event[logging.Log]:
  84. if msg.Type == logging.EventLogCreated {
  85. // Add the new log to our list
  86. i.logs = append([]logging.Log{msg.Payload}, i.logs...)
  87. // Keep the list at a reasonable size
  88. if len(i.logs) > 100 {
  89. i.logs = i.logs[:100]
  90. }
  91. return i, i.updateRows()
  92. }
  93. return i, nil
  94. }
  95. // Only process keyboard input when focused
  96. if _, ok := msg.(tea.KeyMsg); ok && !i.focused {
  97. return i, nil
  98. }
  99. t, cmd := i.table.Update(msg)
  100. cmds = append(cmds, cmd)
  101. i.table = t
  102. selectedRow := i.table.SelectedRow()
  103. if selectedRow != nil {
  104. // Only send message if it's a new selection
  105. if i.selectedLogID != selectedRow[0] {
  106. cmds = append(cmds, func() tea.Msg {
  107. for _, log := range i.logs {
  108. if log.ID == selectedRow[0] {
  109. return selectedLogMsg(log)
  110. }
  111. }
  112. return nil
  113. })
  114. }
  115. i.selectedLogID = selectedRow[0]
  116. }
  117. return i, tea.Batch(cmds...)
  118. }
  119. func (i *tableCmp) View() string {
  120. t := theme.CurrentTheme()
  121. defaultStyles := table.DefaultStyles()
  122. defaultStyles.Selected = defaultStyles.Selected.Foreground(t.Primary())
  123. i.table.SetStyles(defaultStyles)
  124. return i.table.View()
  125. }
  126. func (i *tableCmp) GetSize() (int, int) {
  127. return i.table.Width(), i.table.Height()
  128. }
  129. func (i *tableCmp) SetSize(width int, height int) tea.Cmd {
  130. i.table.SetWidth(width)
  131. i.table.SetHeight(height)
  132. columns := i.table.Columns()
  133. // Calculate widths for visible columns
  134. timeWidth := 8 // Fixed width for Time column
  135. levelWidth := 7 // Fixed width for Level column
  136. // Message column gets the remaining space
  137. messageWidth := width - timeWidth - levelWidth - 5 // 5 for padding and borders
  138. // Set column widths
  139. columns[0].Width = 0 // ID column (hidden)
  140. columns[1].Width = timeWidth
  141. columns[2].Width = levelWidth
  142. columns[3].Width = messageWidth
  143. i.table.SetColumns(columns)
  144. return nil
  145. }
  146. func (i *tableCmp) BindingKeys() []key.Binding {
  147. return layout.KeyMapToSlice(i.table.KeyMap)
  148. }
  149. func NewLogsTable(app *app.App) TableComponent {
  150. columns := []table.Column{
  151. {Title: "ID", Width: 0}, // ID column with zero width
  152. {Title: "Time", Width: 8},
  153. {Title: "Level", Width: 7},
  154. {Title: "Message", Width: 30},
  155. }
  156. tableModel := table.New(
  157. table.WithColumns(columns),
  158. )
  159. tableModel.Focus()
  160. return &tableCmp{
  161. app: app,
  162. table: tableModel,
  163. logs: []logging.Log{},
  164. }
  165. }
  166. // Focus implements the focusable interface
  167. func (i *tableCmp) Focus() {
  168. i.focused = true
  169. i.table.Focus()
  170. }
  171. // Blur implements the blurable interface
  172. func (i *tableCmp) Blur() {
  173. i.focused = false
  174. i.table.Blur()
  175. }