Added bento :)

This commit is contained in:
2026-05-01 14:10:22 -06:00
parent fec14022cd
commit 9ea2cf8c34
17 changed files with 1304 additions and 253 deletions
+243 -173
View File
@@ -8,17 +8,19 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/atridad/gitea-wrapped/pkg/gitea"
"github.com/atridad/gitea-wrapped/pkg/models"
"github.com/atridad/gitea-wrapped/pkg/stats"
"github.com/atridad/wrapped-cli/pkg/config"
"github.com/atridad/wrapped-cli/pkg/gitea"
"github.com/atridad/wrapped-cli/pkg/models"
"github.com/atridad/wrapped-cli/pkg/stats"
)
type screenType int
const (
inputScreenType screenType = iota
loadingScreenType
reportScreenType
menuScreenType screenType = iota
inputScreenType
loadingScreenType
reportScreenType
)
type tickMsg time.Time
@@ -26,215 +28,283 @@ type progressMsg string
type errorMsg string
type doneMsg struct {
repos []gitea.GiteaRepo
commits []models.Commit
username string
repos []gitea.GiteaRepo
commits []models.Commit
username string
}
type App struct {
screen screenType
inputScreen *InputScreen
reportScreen *ReportScreen
loadingMsg string
err error
giteaClient *gitea.Client
analyzer *stats.Analyzer
spinnerFrame int
repos []gitea.GiteaRepo
username string
screen screenType
menuScreen *MenuScreen
inputScreen *InputScreen
reportScreen *ReportScreen
loadingMsg string
err error
giteaClient *gitea.Client
analyzer *stats.Analyzer
spinnerFrame int
repos []gitea.GiteaRepo
username string
config *config.Config
}
func NewApp() *App {
return &App{
screen: inputScreenType,
inputScreen: NewInputScreen(),
analyzer: stats.NewAnalyzer(),
}
cfg, err := config.LoadConfig()
if err != nil || cfg == nil {
cfg = &config.Config{Connections: []config.Connection{}}
}
screen := menuScreenType
var inputScreen *InputScreen
if len(cfg.Connections) == 0 {
screen = inputScreenType
inputScreen = NewInputScreen()
}
return &App{
screen: screen,
menuScreen: NewMenuScreen(cfg),
inputScreen: inputScreen,
config: cfg,
analyzer: stats.NewAnalyzer(),
}
}
func (a *App) Init() tea.Cmd {
return a.inputScreen.Init()
if a.screen == inputScreenType && a.inputScreen != nil {
return a.inputScreen.Init()
}
return nil
}
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
return a, tea.Quit
}
case tickMsg:
if a.screen == loadingScreenType {
a.spinnerFrame = (a.spinnerFrame + 1) % 4
return a, tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
}
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
return a, tea.Quit
}
case tickMsg:
if a.screen == loadingScreenType {
a.spinnerFrame = (a.spinnerFrame + 1) % 4
return a, tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
}
switch a.screen {
case inputScreenType:
_, cmd := a.inputScreen.Update(msg)
switch a.screen {
case menuScreenType:
_, cmd := a.menuScreen.Update(msg)
if a.inputScreen.IsDone() {
url, username, token := a.inputScreen.GetCredentials()
if a.menuScreen.IsNewConnection() {
a.screen = inputScreenType
a.menuScreen.ResetSelection()
a.inputScreen = NewInputScreen()
return a, a.inputScreen.Init()
}
if url == "" {
a.inputScreen.err = fmt.Errorf("URL is required")
a.inputScreen.done = false
return a, cmd
}
if username == "" {
a.inputScreen.err = fmt.Errorf("username is required")
a.inputScreen.done = false
return a, cmd
}
if token == "" {
a.inputScreen.err = fmt.Errorf("token is required")
a.inputScreen.done = false
return a, cmd
}
if selected := a.menuScreen.GetSelectedName(); selected != "" {
conn := a.config.GetConnection(selected)
if conn != nil {
a.menuScreen.ResetSelection()
a.giteaClient = gitea.NewClient(conn.URL, conn.Token)
a.username = conn.Username
a.screen = loadingScreenType
a.loadingMsg = "Connecting to Gitea..."
a.spinnerFrame = 0
a.giteaClient = gitea.NewClient(url, token)
a.username = username
a.screen = loadingScreenType
a.loadingMsg = "Connecting to Gitea..."
a.spinnerFrame = 0
return a, tea.Batch(
tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
return tickMsg(t)
}),
a.testConnection())
}
}
return a, tea.Batch(cmd,
tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
return tickMsg(t)
}),
a.testConnection())
}
return a, cmd
return a, cmd
case inputScreenType:
_, cmd := a.inputScreen.Update(msg)
case loadingScreenType:
switch msg := msg.(type) {
case progressMsg:
a.loadingMsg = string(msg)
if a.inputScreen.IsDone() {
url, username, token, connName := a.inputScreen.GetCredentials()
switch string(msg) {
case "Fetching repositories...":
return a, a.fetchRepos()
case "Fetching commits...":
return a, a.fetchCommits()
}
return a, nil
if url == "" {
a.inputScreen.err = fmt.Errorf("URL is required")
a.inputScreen.done = false
return a, cmd
}
if username == "" {
a.inputScreen.err = fmt.Errorf("username is required")
a.inputScreen.done = false
return a, cmd
}
if token == "" {
a.inputScreen.err = fmt.Errorf("token is required")
a.inputScreen.done = false
return a, cmd
}
case errorMsg:
a.err = fmt.Errorf("%s", msg)
a.loadingMsg = "Error"
return a, nil
if connName != "" {
a.config.AddConnection(config.Connection{
Name: connName,
URL: url,
Username: username,
Token: token,
})
a.config.Save()
}
case doneMsg:
a.analyzer.AddRepos(msg.repos)
a.analyzer.AddCommits(msg.commits)
userStats := a.analyzer.Generate(msg.username)
a.reportScreen = NewReportScreen(userStats)
a.screen = reportScreenType
return a, nil
}
a.giteaClient = gitea.NewClient(url, token)
a.username = username
a.screen = loadingScreenType
a.loadingMsg = "Connecting to Gitea..."
a.spinnerFrame = 0
case reportScreenType:
if a.reportScreen != nil {
_, cmd := a.reportScreen.Update(msg)
return a, cmd
}
}
return a, tea.Batch(cmd,
tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
return tickMsg(t)
}),
a.testConnection())
}
return a, nil
return a, cmd
case loadingScreenType:
switch msg := msg.(type) {
case progressMsg:
a.loadingMsg = string(msg)
switch string(msg) {
case "Fetching repositories...":
return a, a.fetchRepos()
case "Fetching commits...":
return a, a.fetchCommits()
}
return a, nil
case errorMsg:
a.err = fmt.Errorf("%s", msg)
a.loadingMsg = "Error"
return a, nil
case doneMsg:
a.analyzer.AddRepos(msg.repos)
a.analyzer.AddCommits(msg.commits)
userStats := a.analyzer.Generate(msg.username)
a.reportScreen = NewReportScreen(userStats)
a.screen = reportScreenType
return a, nil
}
case reportScreenType:
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" {
a.screen = menuScreenType
a.menuScreen = NewMenuScreen(a.config)
a.reportScreen = nil
a.analyzer = stats.NewAnalyzer()
return a, nil
}
}
if a.reportScreen != nil {
_, cmd := a.reportScreen.Update(msg)
return a, cmd
}
}
return a, nil
}
func (a *App) View() string {
switch a.screen {
case inputScreenType:
return a.inputScreen.View()
case loadingScreenType:
return a.renderLoadingScreen()
case reportScreenType:
if a.reportScreen != nil {
return a.reportScreen.View()
}
}
return ""
switch a.screen {
case menuScreenType:
return a.menuScreen.View()
case inputScreenType:
return a.inputScreen.View()
case loadingScreenType:
return a.renderLoadingScreen()
case reportScreenType:
if a.reportScreen != nil {
return a.reportScreen.View()
}
}
return ""
}
func (a *App) renderLoadingScreen() string {
var s string
s += lipgloss.NewStyle().
Foreground(lipgloss.Color("212")).
Bold(true).
Render("🎵 Gitea Wrapped") + "\n\n"
var s string
s += lipgloss.NewStyle().
Foreground(lipgloss.Color("212")).
Bold(true).
Render("Gitea Wrapped") + "\n\n"
spinners := []string{"⠋", "⠙", "⠹", "⠸"}
spinner := spinners[a.spinnerFrame%4]
spinners := []string{"⠋", "⠙", "⠹", "⠸"}
spinner := spinners[a.spinnerFrame%4]
s += fmt.Sprintf("%s %s\n", spinner, a.loadingMsg)
s += fmt.Sprintf("%s %s\n", spinner, a.loadingMsg)
if a.err != nil {
s += "\n" + lipgloss.NewStyle().
Foreground(lipgloss.Color("1")).
Render(fmt.Sprintf("Error: %v", a.err)) + "\n"
if a.err != nil {
s += "\n" + lipgloss.NewStyle().
Foreground(lipgloss.Color("1")).
Render(fmt.Sprintf("Error: %v", a.err)) + "\n"
}
return s
}
return s
}
// testConnection tests API connection
func (a *App) testConnection() tea.Cmd {
return func() tea.Msg {
if err := a.giteaClient.TestConnection(); err != nil {
return errorMsg(fmt.Sprintf("failed to connect: %v", err))
}
return progressMsg("Fetching repositories...")
}
return func() tea.Msg {
if err := a.giteaClient.TestConnection(); err != nil {
return errorMsg(fmt.Sprintf("failed to connect: %v", err))
}
return progressMsg("Fetching repositories...")
}
}
// fetchRepos fetches all repositories
func (a *App) fetchRepos() tea.Cmd {
return func() tea.Msg {
repos, err := a.giteaClient.GetUserRepos()
if err != nil {
return errorMsg(fmt.Sprintf("failed to fetch repos: %v", err))
return func() tea.Msg {
repos, err := a.giteaClient.GetUserRepos()
if err != nil {
return errorMsg(fmt.Sprintf("failed to fetch repos: %v", err))
}
a.repos = repos
return progressMsg("Fetching commits...")
}
}
a.repos = repos
return progressMsg("Fetching commits...")
}
}
// fetchCommits fetches commits from all repos
func (a *App) fetchCommits() tea.Cmd {
return func() tea.Msg {
var commits []models.Commit
return func() tea.Msg {
var repoRefs []gitea.RepoRef
for _, repo := range a.repos {
parts := strings.Split(repo.FullName, "/")
if len(parts) < 2 {
continue
}
repoRefs = append(repoRefs, gitea.RepoRef{
Owner: parts[0],
Name: repo.Name,
})
}
for i, repo := range a.repos {
giteaCommits, err := a.giteaClient.GetReposCommitsParallel(repoRefs)
if err != nil {
return errorMsg(fmt.Sprintf("failed to fetch commits: %v", err))
}
parts := strings.Split(repo.FullName, "/")
if len(parts) < 2 {
continue
}
owner := parts[0]
giteaCommits, err := a.giteaClient.GetRepoCommits(owner, repo.Name)
if err != nil {
continue
}
for _, gc := range giteaCommits {
commits = append(commits, models.Commit{
SHA: gc.SHA,
Message: gc.Commit.Message,
Author: gc.Commit.Author.Name,
Timestamp: gc.Commit.Author.Date,
RepoName: repo.Name,
})
}
}
return doneMsg{a.repos, commits, a.username}
}
var commits []models.Commit
for _, gc := range giteaCommits {
commits = append(commits, models.Commit{
SHA: gc.SHA,
Message: gc.Commit.Message,
Author: gc.Commit.Author.Name,
AuthorEmail: gc.Commit.Author.Email,
Timestamp: gc.Commit.Author.Date,
RepoName: gc.RepoName,
})
}
return doneMsg{a.repos, commits, a.username}
}
}
+10
View File
@@ -0,0 +1,10 @@
package ui
import (
"github.com/atridad/wrapped-cli/pkg/models"
"github.com/atridad/wrapped-cli/pkg/reports"
)
func ExportReport(stats *models.UserStats) (string, error) {
return reports.SaveReport(stats)
}
+28 -6
View File
@@ -14,17 +14,20 @@ const (
urlInput inputScreenState = iota
usernameInput
tokenInput
connNameInput
)
type InputScreen struct {
urlField textinput.Model
usernameField textinput.Model
tokenField textinput.Model
connNameField textinput.Model
focusedField inputScreenState
err error
url string
username string
token string
connName string
done bool
}
@@ -40,10 +43,14 @@ func NewInputScreen() *InputScreen {
tokenField.Placeholder = "your_access_token"
tokenField.EchoMode = textinput.EchoPassword
connNameField := textinput.New()
connNameField.Placeholder = "(optional) Connection name"
return &InputScreen{
urlField: urlField,
usernameField: usernameField,
tokenField: tokenField,
connNameField: connNameField,
focusedField: urlInput,
}
}
@@ -72,6 +79,11 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return is, nil
case tokenInput:
is.token = is.tokenField.Value()
is.focusedField = connNameInput
is.connNameField.Focus()
return is, nil
case connNameInput:
is.connName = is.connNameField.Value()
is.done = true
return is, nil
}
@@ -83,17 +95,20 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
is.username = is.usernameField.Value()
case tokenInput:
is.token = is.tokenField.Value()
case connNameInput:
is.connName = is.connNameField.Value()
}
if msg.Type == tea.KeyTab {
is.focusedField = (is.focusedField + 1) % 3
is.focusedField = (is.focusedField + 1) % 4
} else {
is.focusedField = (is.focusedField - 1 + 3) % 3
is.focusedField = (is.focusedField - 1 + 4) % 4
}
is.urlField.Blur()
is.usernameField.Blur()
is.tokenField.Blur()
is.connNameField.Blur()
switch is.focusedField {
case urlInput:
@@ -102,6 +117,8 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
is.usernameField.Focus()
case tokenInput:
is.tokenField.Focus()
case connNameInput:
is.connNameField.Focus()
}
return is, nil
}
@@ -119,6 +136,8 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
is.usernameField, cmd = is.usernameField.Update(msg)
case tokenInput:
is.tokenField, cmd = is.tokenField.Update(msg)
case connNameInput:
is.connNameField, cmd = is.connNameField.Update(msg)
}
return is, cmd
@@ -132,7 +151,7 @@ func (is *InputScreen) View() string {
Bold(true).
Render("Gitea Wrapped") + "\n\n"
s += "Enter your Gitea credentials to get started:\n\n"
s += "Enter your Gitea credentials:\n\n"
s += "Gitea URL\n"
s += is.urlField.View() + "\n\n"
@@ -143,6 +162,9 @@ func (is *InputScreen) View() string {
s += "Access Token\n"
s += is.tokenField.View() + "\n\n"
s += "Connection Name (optional - to save for later)\n"
s += is.connNameField.View() + "\n\n"
if is.err != nil {
s += lipgloss.NewStyle().
Foreground(lipgloss.Color("1")).
@@ -151,7 +173,7 @@ func (is *InputScreen) View() string {
s += lipgloss.NewStyle().
Foreground(lipgloss.Color("8")).
Render("(press Tab to navigate, Enter to continue, Ctrl+C to quit)") + "\n"
Render("(Tab to navigate, Enter to continue, Ctrl+C to quit)") + "\n"
return s
}
@@ -160,8 +182,8 @@ func (is *InputScreen) IsDone() bool {
return is.done
}
func (is *InputScreen) GetCredentials() (string, string, string) {
return is.url, is.username, is.token
func (is *InputScreen) GetCredentials() (url, username, token, connName string) {
return is.url, is.username, is.token, is.connName
}
type errMsg error
+122
View File
@@ -0,0 +1,122 @@
package ui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/atridad/wrapped-cli/pkg/config"
)
type MenuScreen struct {
connections []config.Connection
selected int
newSelected bool
confirmed bool
}
func NewMenuScreen(cfg *config.Config) *MenuScreen {
return &MenuScreen{
connections: cfg.Connections,
selected: 0,
confirmed: false,
}
}
func (m *MenuScreen) Init() tea.Cmd {
return nil
}
func (m *MenuScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "up", "k":
if m.selected > 0 {
m.selected--
}
case "down", "j":
menuItems := len(m.connections) + 1
if m.selected < menuItems-1 {
m.selected++
}
case "enter":
if m.selected == len(m.connections) {
m.newSelected = true
} else if m.selected < len(m.connections) {
m.confirmed = true
}
}
}
return m, nil
}
func (m *MenuScreen) View() string {
var s string
s += lipgloss.NewStyle().
Foreground(lipgloss.Color("212")).
Bold(true).
Render("Gitea Wrapped") + "\n\n"
if len(m.connections) == 0 {
s += "No saved connections yet.\n\n"
newPrefix := " "
if m.selected == 0 {
newPrefix = "→ "
}
if m.selected == 1 {
}
s += fmt.Sprintf("%s+ New Connection\n", newPrefix)
s += "\n" + lipgloss.NewStyle().
Foreground(lipgloss.Color("8")).
Render("↑/↓ navigate | Enter to select | Ctrl+C to quit")
} else {
s += "Select a connection:\n\n"
for i, conn := range m.connections {
prefix := " "
if i == m.selected {
prefix = "→ "
}
s += fmt.Sprintf("%s%s (%s)\n", prefix, conn.Name, conn.URL)
}
newPrefix := " "
if m.selected == len(m.connections) {
newPrefix = "→ "
}
if m.selected == len(m.connections)+1 {
}
s += fmt.Sprintf("%s+ New Connection\n", newPrefix)
s += "\n" + lipgloss.NewStyle().
Foreground(lipgloss.Color("8")).
Render("↑/↓ navigate | Enter to select | Ctrl+C to quit")
}
return s
}
func (m *MenuScreen) IsNewConnection() bool {
return m.newSelected
}
func (m *MenuScreen) GetSelectedName() string {
if m.confirmed && m.selected < len(m.connections) {
return m.connections[m.selected].Name
}
return ""
}
func (m *MenuScreen) ResetSelection() {
m.newSelected = false
m.confirmed = false
m.selected = 0
}
+18 -8
View File
@@ -7,20 +7,23 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/atridad/gitea-wrapped/pkg/models"
"github.com/atridad/wrapped-cli/pkg/models"
)
type ReportScreen struct {
stats *models.UserStats
page int
maxPages int
stats *models.UserStats
page int
maxPages int
exportFile string
}
func NewReportScreen(stats *models.UserStats) *ReportScreen {
exportFile, _ := ExportReport(stats)
return &ReportScreen{
stats: stats,
page: 0,
maxPages: 3,
stats: stats,
page: 0,
maxPages: 3,
exportFile: exportFile,
}
}
@@ -67,7 +70,14 @@ func (rs *ReportScreen) View() string {
Foreground(lipgloss.Color("8")).
Render(fmt.Sprintf("Page %d/%d | ← → to navigate | q to quit", rs.page+1, rs.maxPages))
return header + "\n" + content + "\n\n" + footer
report := header + "\n" + content + "\n\n" + footer
if rs.exportFile != "" {
report += "\n" + lipgloss.NewStyle().
Foreground(lipgloss.Color("10")).
Render(fmt.Sprintf("✓ Report saved: %s", rs.exportFile))
}
return report
}
func (rs *ReportScreen) renderOverviewPage() string {