diff --git a/README.md b/README.md index 021281a..5613045 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,12 @@ thread_list_width_percent = 33 # 20-60 [paths] scroll = false scroll_interval = "350ms" # minimum 50ms + +[threads] +# Each status must occur exactly once. "outdated" means unresolved and outdated; +# resolved threads remain in "resolved" even when they are also outdated. +status_order = ["unresolved", "outdated", "resolved"] +within_status = "file" # "file" or "timestamp" (oldest first) ``` Command-line flags override the configuration. `GH_REPO` overrides the diff --git a/config.go b/config.go index a5fe85c..9740de7 100644 --- a/config.go +++ b/config.go @@ -33,6 +33,7 @@ type Config struct { Endpoint string `toml:"endpoint"` Display DisplayConfig `toml:"display"` Paths PathConfig `toml:"paths"` + Threads ThreadConfig `toml:"threads"` } type DisplayConfig struct { @@ -45,6 +46,11 @@ type PathConfig struct { ScrollInterval configDuration `toml:"scroll_interval"` } +type ThreadConfig struct { + StatusOrder []string `toml:"status_order"` + WithinStatus string `toml:"within_status"` +} + func defaultConfig() Config { return Config{ Theme: "dark", @@ -59,6 +65,10 @@ func defaultConfig() Config { Scroll: false, ScrollInterval: configDuration{350 * time.Millisecond}, }, + Threads: ThreadConfig{ + StatusOrder: []string{"unresolved", "outdated", "resolved"}, + WithinStatus: "file", + }, } } @@ -129,8 +139,35 @@ func validateConfig(config Config) error { if config.Display.ThreadListWidthPercent < 20 || config.Display.ThreadListWidthPercent > 60 { return fmt.Errorf("display.thread_list_width_percent must be between 20 and 60") } + if err := validateThreadStatusOrder(config.Threads.StatusOrder); err != nil { + return err + } + switch config.Threads.WithinStatus { + case "file", "timestamp": + default: + return fmt.Errorf("threads.within_status must be file or timestamp") + } if config.ShowAll && config.Repository == "" { return fmt.Errorf("show_all requires repository") } return nil } + +func validateThreadStatusOrder(order []string) error { + if len(order) != 3 { + return fmt.Errorf("threads.status_order must contain unresolved, outdated, and resolved exactly once") + } + seen := map[string]bool{} + for _, status := range order { + switch status { + case "unresolved", "outdated", "resolved": + if seen[status] { + return fmt.Errorf("threads.status_order contains %q more than once", status) + } + seen[status] = true + default: + return fmt.Errorf("threads.status_order contains unknown status %q", status) + } + } + return nil +} diff --git a/config_test.go b/config_test.go index 0b71616..74a07ce 100644 --- a/config_test.go +++ b/config_test.go @@ -40,6 +40,10 @@ thread_list_width_percent = 45 [paths] scroll = true scroll_interval = "125ms" + +[threads] +status_order = ["resolved", "unresolved", "outdated"] +within_status = "timestamp" ` if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatal(err) @@ -54,7 +58,9 @@ scroll_interval = "125ms" if got.Theme != "light" || got.RefreshInterval.Duration != 25*time.Second || got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 || got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 || - !got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond { + !got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond || + strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" || + got.Threads.WithinStatus != "timestamp" { t.Fatalf("config = %#v", got) } } @@ -126,3 +132,16 @@ func TestValidateConfigRejectsUnsafeAnimationRate(t *testing.T) { t.Fatal("unsafe path scrolling interval was accepted") } } + +func TestValidateConfigRejectsInvalidThreadOrdering(t *testing.T) { + config := defaultConfig() + config.Threads.StatusOrder = []string{"unresolved", "resolved", "resolved"} + if err := validateConfig(config); err == nil { + t.Fatal("duplicate thread status was accepted") + } + config = defaultConfig() + config.Threads.WithinStatus = "author" + if err := validateConfig(config); err == nil { + t.Fatal("unknown within-status ordering was accepted") + } +} diff --git a/main.go b/main.go index d850fda..4438474 100644 --- a/main.go +++ b/main.go @@ -96,6 +96,8 @@ func main() { ThreadListWidthPercent: config.Display.ThreadListWidthPercent, PathScroll: config.Paths.Scroll, PathScrollInterval: config.Paths.ScrollInterval.Duration, + ThreadStatusOrder: config.Threads.StatusOrder, + ThreadWithinStatus: config.Threads.WithinStatus, }, ) if _, err := tea.NewProgram(app, tea.WithAltScreen()).Run(); err != nil { diff --git a/tui.go b/tui.go index c81164c..b197265 100644 --- a/tui.go +++ b/tui.go @@ -73,6 +73,8 @@ type App struct { pathScroll bool pathScrollInterval time.Duration pathScrollStep int + threadStatusOrder []string + threadWithinStatus string } type AppSettings struct { @@ -80,6 +82,8 @@ type AppSettings struct { ThreadListWidthPercent int PathScroll bool PathScrollInterval time.Duration + ThreadStatusOrder []string + ThreadWithinStatus string } func defaultAppSettings() AppSettings { @@ -87,6 +91,8 @@ func defaultAppSettings() AppSettings { FoldResolved: true, ThreadListWidthPercent: 33, PathScrollInterval: 350 * time.Millisecond, + ThreadStatusOrder: []string{"unresolved", "outdated", "resolved"}, + ThreadWithinStatus: "file", } } @@ -107,6 +113,8 @@ func NewAppWithSettings( folded: make(map[string]bool), loading: true, foldResolved: settings.FoldResolved, threadListWidthPercent: settings.ThreadListWidthPercent, pathScroll: settings.PathScroll, pathScrollInterval: settings.PathScrollInterval, + threadStatusOrder: append([]string(nil), settings.ThreadStatusOrder...), + threadWithinStatus: settings.ThreadWithinStatus, } } @@ -203,6 +211,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.threadIndex < len(m.details.Threads) { selected = m.details.Threads[m.threadIndex].ID } + sortReviewThreads(msg.details.Threads, m.threadStatusOrder, m.threadWithinStatus) m.details = msg.details m.threadIndex = indexThread(m.details.Threads, selected) if selected != "" && (len(m.details.Threads) == 0 || m.details.Threads[m.threadIndex].ID != selected) { @@ -444,6 +453,82 @@ func (m App) matchingThreadIndices() []int { return indices } +func sortReviewThreads(threads []ReviewThread, statusOrder []string, withinStatus string) { + ranks := map[string]int{ + "unresolved": 0, + "outdated": 1, + "resolved": 2, + } + for rank, status := range statusOrder { + ranks[status] = rank + } + if withinStatus == "" { + withinStatus = "file" + } + sort.SliceStable(threads, func(i, j int) bool { + left, right := threads[i], threads[j] + leftRank, rightRank := ranks[threadStatus(left)], ranks[threadStatus(right)] + if leftRank != rightRank { + return leftRank < rightRank + } + if withinStatus == "timestamp" { + if less, decided := compareThreadTimestamps(left, right); decided { + return less + } + } + leftPath, rightPath := strings.ToLower(left.Path), strings.ToLower(right.Path) + if leftPath != rightPath { + return leftPath < rightPath + } + if left.Line != right.Line { + return left.Line < right.Line + } + if withinStatus != "timestamp" { + if less, decided := compareThreadTimestamps(left, right); decided { + return less + } + } + return false + }) +} + +func threadStatus(thread ReviewThread) string { + if thread.IsResolved { + return "resolved" + } + if thread.IsOutdated { + return "outdated" + } + return "unresolved" +} + +func threadOpenedAt(thread ReviewThread) time.Time { + var opened time.Time + for _, comment := range thread.Comments { + if comment.CreatedAt.IsZero() || (!opened.IsZero() && !comment.CreatedAt.Before(opened)) { + continue + } + opened = comment.CreatedAt + } + return opened +} + +func compareThreadTimestamps(left, right ReviewThread) (less, decided bool) { + leftTime, rightTime := threadOpenedAt(left), threadOpenedAt(right) + switch { + case leftTime.IsZero() && rightTime.IsZero(): + return false, false + case leftTime.IsZero(): + return false, true + case rightTime.IsZero(): + return true, true + case !leftTime.Equal(rightTime): + return leftTime.Before(rightTime), true + default: + return false, false + } +} + func (m *App) toStart() { if m.screen == prScreen { m.prIndex = 0 diff --git a/tui_test.go b/tui_test.go index c1a0b30..34e5d53 100644 --- a/tui_test.go +++ b/tui_test.go @@ -89,6 +89,52 @@ func TestSelectionSurvivesRefresh(t *testing.T) { } } +func TestDefaultThreadOrderingUsesStatusThenFile(t *testing.T) { + threads := []ReviewThread{ + {ID: "resolved-outdated", Path: "a.go", IsResolved: true, IsOutdated: true}, + {ID: "outdated", Path: "a.go", IsOutdated: true}, + {ID: "current-z", Path: "z.go"}, + {ID: "resolved", Path: "z.go", IsResolved: true}, + {ID: "current-a", Path: "a.go"}, + } + settings := defaultAppSettings() + sortReviewThreads(threads, settings.ThreadStatusOrder, settings.ThreadWithinStatus) + got := make([]string, len(threads)) + for i, thread := range threads { + got[i] = thread.ID + } + want := []string{"current-a", "current-z", "outdated", "resolved-outdated", "resolved"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("thread order = %v, want %v", got, want) + } +} + +func TestCustomThreadOrderingUsesOpeningTimestamp(t *testing.T) { + base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + threads := []ReviewThread{ + {ID: "current", Path: "a.go", Comments: []ReviewComment{{CreatedAt: base}}}, + {ID: "resolved-new", IsResolved: true, Path: "a.go", Comments: []ReviewComment{{CreatedAt: base.Add(2 * time.Hour)}}}, + {ID: "outdated", IsOutdated: true, Path: "a.go", Comments: []ReviewComment{{CreatedAt: base.Add(time.Hour)}}}, + {ID: "resolved-old", IsResolved: true, Path: "z.go", Comments: []ReviewComment{ + {CreatedAt: base.Add(3 * time.Hour)}, + {CreatedAt: base.Add(-time.Hour)}, + }}, + } + sortReviewThreads( + threads, + []string{"resolved", "outdated", "unresolved"}, + "timestamp", + ) + got := make([]string, len(threads)) + for i, thread := range threads { + got[i] = thread.ID + } + want := []string{"resolved-old", "resolved-new", "outdated", "current"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("thread order = %v, want %v", got, want) + } +} + func TestPaneFocusAndNavigation(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen = threadScreen