diff --git a/internal/ui/app.go b/internal/ui/app.go index 3d08a5b..f09acf9 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -103,6 +103,7 @@ type MainModel struct { animTick int err interface{} analysisCancel context.CancelFunc + compareCtxCancel context.CancelFunc analysisType string compareStep int compareInput1 string @@ -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 @@ -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 @@ -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()) @@ -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 } @@ -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 = "" @@ -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 @@ -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 @@ -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 @@ -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) @@ -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) @@ -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) @@ -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, "/") @@ -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) @@ -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) diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go new file mode 100644 index 0000000..6a4f1d7 --- /dev/null +++ b/internal/ui/app_test.go @@ -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") + } +}