Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 98 additions & 2 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ type MainModel struct {
animTick int
err interface{}
analysisCancel context.CancelFunc
compareCtxCancel context.CancelFunc
analysisType string
compareStep int
compareInput1 string
Expand All @@ -115,6 +116,10 @@ type MainModel struct {
progress *ProgressTracker
cacheStatus string
initialCmd tea.Cmd

// Analysis state and debounce
analysisInProgress bool
lastSubmitTime time.Time
}

// NewMainModel creates a new MainModel with initialized sub-models
Expand Down Expand Up @@ -236,6 +241,11 @@ func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.analysisCancel()
m.analysisCancel = nil
}
if m.compareCtxCancel != nil {
m.compareCtxCancel()
m.compareCtxCancel = nil
}
m.analysisInProgress = false
m.state = stateMenu
return m, nil

Expand Down Expand Up @@ -320,6 +330,13 @@ func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Handle messages from input model
switch msg := msg.(type) {
case AnalyzeRepoMsg:
// Duplicate Request Guard & Debounce
if m.analysisInProgress || (time.Since(m.lastSubmitTime) < 2*time.Second) {
return m, nil
}

m.analysisInProgress = true
m.lastSubmitTime = time.Now()
m.state = stateLoading
m.loading.SetRepoName(msg.repoName)
ctx, cancel := context.WithCancel(context.Background())
Expand All @@ -339,8 +356,17 @@ func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Handle messages from compare input model
switch msg := msg.(type) {
case CompareReposMsg:
// Duplicate Request Guard & Debounce
if m.analysisInProgress || (time.Since(m.lastSubmitTime) < 2*time.Second) {
return m, nil
}

m.analysisInProgress = true
m.lastSubmitTime = time.Now()
m.state = stateCompareLoading
cmds = append(cmds, m.compareRepos(msg.Repo1, msg.Repo2), TickProgressCmd())
ctx, cancel := context.WithCancel(context.Background())
m.compareCtxCancel = cancel
cmds = append(cmds, m.compareRepos(ctx, msg.Repo1, msg.Repo2), TickProgressCmd())
case BackToMenuMsg:
m.state = stateMenu
}
Expand All @@ -352,15 +378,24 @@ func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

switch msg := msg.(type) {
case CompareResult:
m.analysisInProgress = false
m.compareResult.result = &msg
m.state = stateCompareResult
m.err = nil
m.compareCtxCancel = nil
case error:
m.analysisInProgress = false
m.err = msg
m.state = stateCompareInput
m.compareStep = 0
m.compareCtxCancel = nil
case tea.KeyMsg:
if msg.String() == "esc" {
if m.compareCtxCancel != nil {
m.compareCtxCancel()
m.compareCtxCancel = nil
}
m.analysisInProgress = false
m.state = stateMenu
m.compareInput1 = ""
m.compareInput2 = ""
Expand Down Expand Up @@ -417,6 +452,7 @@ func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}

if result, ok := msg.(AnalysisResult); ok {
m.analysisInProgress = false
m.dashboard.SetData(result)
m.dashboard.SetCacheStatus("fresh")
m.state = stateDashboard
Expand All @@ -434,6 +470,7 @@ func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
if cachedResult, ok := msg.(CachedAnalysisResult); ok {
m.analysisInProgress = false
m.dashboard.SetData(cachedResult.Result)
m.dashboard.SetCacheStatus("cached")
m.state = stateDashboard
Expand All @@ -451,6 +488,7 @@ func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
if err, ok := msg.(error); ok {
m.analysisInProgress = false
m.progress = nil
if errors.Is(err, context.Canceled) {
m.err = nil
Expand Down Expand Up @@ -480,12 +518,19 @@ func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "enter":
// Analyze selected favorite
if m.favorites.favorites != nil && len(m.favorites.favorites.Items) > 0 {
// Duplicate Request Guard & Debounce
if m.analysisInProgress || (time.Since(m.lastSubmitTime) < 2*time.Second) {
return m, nil
}

repoName := m.favorites.favorites.Items[m.favoritesCursor].RepoName
m.favorites.favorites.UpdateUsage(repoName)
if err := m.favorites.Save(); err != nil {
log.Printf("Failed to save favorites: %v", err)
m.err = fmt.Errorf("Failed to save favorites: %v", err)
} else {
m.analysisInProgress = true
m.lastSubmitTime = time.Now()
m.input.input = repoName
m.state = stateLoading
m.loading.SetRepoName(repoName)
Expand Down Expand Up @@ -530,7 +575,14 @@ func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "enter":
// Re-analyze selected repo
if len(m.history.Entries) > 0 {
// Duplicate Request Guard & Debounce
if m.analysisInProgress || (time.Since(m.lastSubmitTime) < 2*time.Second) {
return m, nil
}

repoName := m.history.Entries[m.historyCursor].RepoName
m.analysisInProgress = true
m.lastSubmitTime = time.Now()
m.input.input = repoName
m.state = stateLoading
m.loading.SetRepoName(repoName)
Expand Down Expand Up @@ -762,6 +814,13 @@ func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
if key.String() == "." {
if m.dashboard.data.Repo != nil {
// Duplicate Request Guard & Debounce
if m.analysisInProgress || (time.Since(m.lastSubmitTime) < 2*time.Second) {
return m, nil
}

m.analysisInProgress = true
m.lastSubmitTime = time.Now()
m.input.input = m.dashboard.data.Repo.FullName
m.state = stateLoading
m.loading.SetRepoName(m.input.input)
Expand Down Expand Up @@ -1397,8 +1456,12 @@ func (m MainModel) compareResultView() string {
)
}

func (m MainModel) compareRepos(repo1Name, repo2Name string) tea.Cmd {
func (m MainModel) compareRepos(ctx context.Context, repo1Name, repo2Name string) tea.Cmd {
return func() tea.Msg {
if err := ctx.Err(); err != nil {
return err
}

parts1 := strings.Split(repo1Name, "/")
parts2 := strings.Split(repo2Name, "/")

Expand All @@ -1414,16 +1477,33 @@ func (m MainModel) compareRepos(repo1Name, repo2Name string) tea.Cmd {
token = m.appConfig.GitHubToken
}
client := github.NewClientWithToken(token)
client.SetContext(ctx)

// Analyze first repo
repo1, err := client.GetRepo(parts1[0], parts1[1])
if err != nil {
return fmt.Errorf("failed to fetch %s: %w", repo1Name, err)
}
if err := ctx.Err(); err != nil {
return err
}

commits1, _ := client.GetCommits(parts1[0], parts1[1], 365)
if err := ctx.Err(); err != nil {
return err
}

contributors1, _ := client.GetContributorsWithAvatars(parts1[0], parts1[1], 15)
if err := ctx.Err(); err != nil {
return err
}

languages1, _ := client.GetLanguages(parts1[0], parts1[1])
fileTree1, _ := client.GetFileTree(parts1[0], parts1[1], repo1.DefaultBranch)
if err := ctx.Err(); err != nil {
return err
}

score1 := analyzer.CalculateHealth(repo1, commits1)
busFactor1, busRisk1 := analyzer.BusFactor(contributors1)
maturityScore1, maturityLevel1 := analyzer.RepoMaturityScore(repo1, len(commits1), len(contributors1), false)
Expand All @@ -1446,10 +1526,26 @@ func (m MainModel) compareRepos(repo1Name, repo2Name string) tea.Cmd {
if err != nil {
return fmt.Errorf("failed to fetch %s: %w", repo2Name, err)
}
if err := ctx.Err(); err != nil {
return err
}

commits2, _ := client.GetCommits(parts2[0], parts2[1], 365)
if err := ctx.Err(); err != nil {
return err
}

contributors2, _ := client.GetContributorsWithAvatars(parts2[0], parts2[1], 15)
if err := ctx.Err(); err != nil {
return err
}

languages2, _ := client.GetLanguages(parts2[0], parts2[1])
fileTree2, _ := client.GetFileTree(parts2[0], parts2[1], repo2.DefaultBranch)
if err := ctx.Err(); err != nil {
return err
}

score2 := analyzer.CalculateHealth(repo2, commits2)
busFactor2, busRisk2 := analyzer.BusFactor(contributors2)
maturityScore2, maturityLevel2 := analyzer.RepoMaturityScore(repo2, len(commits2), len(contributors2), false)
Expand Down
88 changes: 88 additions & 0 deletions internal/ui/app_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package ui

import (
"testing"
"time"

"github.com/agnivo988/Repo-lyzer/internal/config"
"github.com/agnivo988/Repo-lyzer/internal/github"
)

func TestAnalysisDeduplicationAndDebounce(t *testing.T) {
// Initialize with defaults
m := NewMainModel(nil, &config.AppSettings{})
m.state = stateInput

// 1. Test normal submission
msg := AnalyzeRepoMsg{repoName: "owner/repo"}
newModel, cmd := m.Update(msg)
m = newModel.(MainModel)

if !m.analysisInProgress {
t.Error("Expected analysisInProgress to be true after first submission")
}
if m.state != stateLoading {
t.Errorf("Expected state to be stateLoading, got %v", m.state)
}
if cmd == nil {
t.Error("Expected a command to be returned for the first submission")
}

// 2. Test immediate duplicate submission (Debounce/Guard)
newModel2, cmd2 := m.Update(msg)
if cmd2 != nil {
t.Error("Expected duplicate submission to be ignored (cmd should be nil)")
}
if !newModel2.(MainModel).analysisInProgress {
t.Error("analysisInProgress should remain true")
}

// 3. Test submission after completion
// Simulate success
mModel, _ := m.Update(AnalysisResult{
Repo: &github.Repo{FullName: "owner/repo"},
})
m = mModel.(MainModel)
if m.analysisInProgress {
t.Error("Expected analysisInProgress to be false after completion")
}

// 4. Test debounce with state transition
m.state = stateInput
m.lastSubmitTime = time.Now() // Just submitted
mModel, cmd = m.Update(msg)
m = mModel.(MainModel)
if cmd != nil {
t.Error("Expected submission within 2s to be ignored by debounce")
}

// 5. Test submission after debounce interval
m.lastSubmitTime = time.Now().Add(-3 * time.Second)
mModel, cmd = m.Update(msg)
m = mModel.(MainModel)
if cmd == nil {
t.Error("Expected submission after debounce interval to be accepted")
}
}

func TestCompareDeduplication(t *testing.T) {
m := NewMainModel(nil, &config.AppSettings{})
m.state = stateCompareInput

msg := CompareReposMsg{Repo1: "owner/repo1", Repo2: "owner/repo2"}
newModel, cmd := m.Update(msg)
m = newModel.(MainModel)

if !m.analysisInProgress {
t.Error("Expected analysisInProgress to be true after compare request")
}
if cmd == nil {
t.Error("Expected a command to be returned for the first compare request")
}

// Duplicate
_, cmd2 := m.Update(msg)
if cmd2 != nil {
t.Error("Expected duplicate compare request to be ignored")
}
}
Loading