Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-05-12 19:12:29 +02:00
commit e8e64eff12
101 changed files with 10081 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
package findings
import (
"bytes"
"text/template"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/paginator"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/glamour/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
type Model struct {
database *db.DB
findings []db.Finding
cursor int
listViewport viewport.Model
bodyViewport viewport.Model
pager paginator.Model
help help.Model
width int
height int
}
func New() Model {
return Model{
listViewport: style.NewViewport(),
bodyViewport: style.NewViewport(),
pager: style.NewPaginator(),
help: style.NewHelp(),
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m *Model) SetDB(d *db.DB) {
m.database = d
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.recalcSizes()
}
func (m *Model) recalcSizes() {
if m.width == 0 {
return
}
m.help.SetWidth(m.width - 2)
inner := m.width - 2
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row
if listVH < 0 {
listVH = 0
}
m.listViewport.SetWidth(inner)
m.listViewport.SetHeight(listVH)
m.pager.PerPage = listVH
if m.pager.PerPage < 1 {
m.pager.PerPage = 1
}
bodyVH := style.PanelContentH(bodyH)
m.bodyViewport.SetWidth(inner)
m.bodyViewport.SetHeight(bodyVH)
m.refreshListViewport()
m.refreshBody()
}
func (m *Model) renderStatusBar() string {
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(findingsKeyMap{}))
}
// RefreshCmd loads findings from the database.
func RefreshCmd(d *db.DB) tea.Cmd {
return func() tea.Msg {
if d == nil {
return FindingsLoadedMsg{}
}
list, err := d.LoadFindings()
if err != nil {
return FindingsLoadedMsg{Err: err}
}
return FindingsLoadedMsg{Findings: list}
}
}
type FindingsLoadedMsg struct {
Findings []db.Finding
Err error
}
func (m *Model) refreshBody() {
if len(m.findings) == 0 {
m.bodyViewport.SetContent("")
return
}
f := m.findings[m.cursor]
rendered := renderMarkdown(f.Description, m.bodyViewport.Width())
m.bodyViewport.SetContent(rendered)
m.bodyViewport.GotoTop()
}
func renderMarkdown(src string, width int) string {
if src == "" {
return style.S.Faint.Render(" (ㆆ _ ㆆ)\nno description")
}
tmpl, err := template.New("").Parse(src)
if err != nil {
return src
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, nil); err != nil {
return src
}
if width < 10 {
width = 80
}
r, err := glamour.NewTermRenderer(
glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
glamour.WithWordWrap(width),
)
if err != nil {
return buf.String()
}
out, err := r.Render(buf.String())
if err != nil {
return buf.String()
}
return out
}
type findingsKeyMap struct{}
func (findingsKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global
f := keys.Keys.Findings
return []key.Binding{g.Up, g.Down, f.Dismiss}
}
func (findingsKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{findingsKeyMap{}.ShortHelp()}
}
+86
View File
@@ -0,0 +1,86 @@
package findings
import (
"log"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case FindingsLoadedMsg:
if msg.Err != nil {
log.Printf("findings load error: %v", msg.Err)
return m, nil
}
m.findings = msg.Findings
if m.cursor >= len(m.findings) {
m.cursor = max(0, len(m.findings)-1)
}
m.pager.SetTotalPages(len(m.findings))
m.refreshListViewport()
m.refreshBody()
return m, nil
case tea.MouseWheelMsg:
switch msg.Button {
case tea.MouseWheelUp:
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
case tea.MouseWheelDown:
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
}
return m, nil
case tea.KeyPressMsg:
g := keys.Keys.Global
f := keys.Keys.Findings
switch {
case key.Matches(msg, g.Up):
if m.cursor > 0 {
m.cursor--
if m.cursor < m.pager.Page*m.pager.PerPage {
m.pager.PrevPage()
}
m.refreshListViewport()
m.refreshBody()
}
case key.Matches(msg, g.Down):
if m.cursor < len(m.findings)-1 {
m.cursor++
if m.cursor >= (m.pager.Page+1)*m.pager.PerPage {
m.pager.NextPage()
}
m.refreshListViewport()
m.refreshBody()
}
case key.Matches(msg, f.Dismiss):
if len(m.findings) > 0 && m.database != nil {
if err := m.database.DismissFinding(m.findings[m.cursor].ID); err != nil {
log.Printf("dismiss finding: %v", err)
return m, nil
}
return m, RefreshCmd(m.database)
}
case key.Matches(msg, g.ScrollUp):
step := m.bodyViewport.Height() / 2
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown):
step := m.bodyViewport.Height() / 2
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
}
}
return m, nil
}
func (m *Model) refreshListViewport() {
m.listViewport.SetContent(m.renderList())
}
+112
View File
@@ -0,0 +1,112 @@
package findings
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
)
func (m Model) View() tea.View {
if m.width == 0 {
return tea.NewView("Loading...")
}
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
content := lipgloss.JoinVertical(lipgloss.Left,
m.renderListPanel(m.width, listH),
m.renderBodyPanel(bodyH),
m.renderStatusBar(),
)
return tea.NewView(content)
}
func (m *Model) renderListPanel(w, h int) string {
s := style.S
dots := s.Faint.Render(m.pager.View())
inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
)
return style.RenderWithTitle(s.PanelFocused, icons.I.Findings+"Findings", inner, w, h)
}
func (m *Model) renderBodyPanel(h int) string {
s := style.S
title := "Description"
if len(m.findings) > 0 {
title = m.findings[m.cursor].Title
}
return style.RenderWithTitle(s.Panel, title, m.bodyViewport.View(), m.width, h)
}
func (m *Model) renderList() string {
s := style.S
if len(m.findings) == 0 {
return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(),
lipgloss.Center, lipgloss.Center,
s.Faint.Render(" (҂◡_◡) ᕤ\nno findings"),
)
}
start, end := m.pager.GetSliceBounds(len(m.findings))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder
for i, f := range m.findings[start:end] {
globalIdx := start + i
selected := globalIdx == m.cursor
sevStyle := style.SeverityStyle(f.Severity)
sevLabel := sevStyle.Width(8).Render(f.Severity)
ts := f.CreatedAt.Format("15:04:05")
w := m.listViewport.Width()
const fixedW = 2 + 8 + 1 + 8 + 1 + 10 + 1
titleW := w - fixedW
if titleW < 0 {
titleW = 0
}
pluginStr := s.Faint.Width(8).Render(util.Truncate(f.PluginName, 8))
var line string
if selected {
bg := lipgloss.NewStyle().Background(s.Selection)
line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
sevStyle.Background(s.Selection).Width(8).Render(f.Severity),
bg.Width(1).Render(""),
bg.Foreground(s.Subtle).Width(8).Render(util.Truncate(f.PluginName, 8)),
bg.Width(1).Render(""),
bg.Foreground(s.Subtle).Width(10).Render(ts),
bg.Width(1).Render(""),
bg.Bold(true).Width(titleW).Render(f.Title),
)
} else {
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
sevLabel,
" ",
pluginStr,
" ",
s.Faint.Width(10).Render(ts),
" ",
s.Bold.Render(f.Title),
)
}
sb.WriteString(fmt.Sprintf("%s\n", line))
}
return sb.String()
}