From 80f24bcfa8b8b798cbe387743d92c43107420a6e Mon Sep 17 00:00:00 2001 From: Pablu Date: Tue, 28 Jul 2026 09:35:15 +0200 Subject: [PATCH] Add configuration --- README.md | 33 +++++++++++ config.go | 136 +++++++++++++++++++++++++++++++++++++++++++++ config_test.go | 128 ++++++++++++++++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + highlight.go | 4 +- main.go | 92 +++++++++++++++++++++++------- markdown.go | 4 ++ theme.go | 58 +++++++++++++++++++ theme_test.go | 20 +++++++ tui.go | 148 ++++++++++++++++++++++++++++++++++++++++++------- tui_test.go | 44 +++++++++++++++ 12 files changed, 629 insertions(+), 41 deletions(-) create mode 100644 config.go create mode 100644 config_test.go create mode 100644 theme.go create mode 100644 theme_test.go diff --git a/README.md b/README.md index 27e4000..021281a 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,39 @@ gh-threads --repo owner/repository \ --endpoint https://github.example.com/api/graphql ``` +## Configuration + +The optional TOML configuration is loaded from +`$GH_THREADS_CONFIG`, `$XDG_CONFIG_HOME/gh-threads/config.toml`, or the +operating system's user configuration directory at `gh-threads/config.toml`. +On Linux this is normally `~/.config/gh-threads/config.toml`. On macOS, +`~/Library/Application Support/gh-threads/config.toml` is preferred, with +`~/.config/gh-threads/config.toml` automatically used as a fallback when it +exists. + +```toml +theme = "dark" # "dark" or "light" +refresh_interval = "10s" +repository = "" # optional owner/repository default +show_all = false # requires repository +limit = 50 +endpoint = "https://api.github.com/graphql" + +[display] +fold_resolved = true +thread_list_width_percent = 33 # 20-60 + +[paths] +scroll = false +scroll_interval = "350ms" # minimum 50ms +``` + +Command-line flags override the configuration. `GH_REPO` overrides the +configured repository when `--repo` is not provided. The corresponding flags +include `--config`, `--theme`, `--poll`, `--fold-resolved`, +`--thread-list-width`, `--path-scroll`, and `--path-scroll-interval`. Boolean +settings can be disabled explicitly, for example `--path-scroll=false`. + ## Keys | Key | Action | diff --git a/config.go b/config.go new file mode 100644 index 0000000..a5fe85c --- /dev/null +++ b/config.go @@ -0,0 +1,136 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/BurntSushi/toml" +) + +type configDuration struct { + time.Duration +} + +func (d *configDuration) UnmarshalText(text []byte) error { + value, err := time.ParseDuration(string(text)) + if err != nil { + return err + } + d.Duration = value + return nil +} + +type Config struct { + Theme string `toml:"theme"` + RefreshInterval configDuration `toml:"refresh_interval"` + Repository string `toml:"repository"` + ShowAll bool `toml:"show_all"` + Limit int `toml:"limit"` + Endpoint string `toml:"endpoint"` + Display DisplayConfig `toml:"display"` + Paths PathConfig `toml:"paths"` +} + +type DisplayConfig struct { + FoldResolved bool `toml:"fold_resolved"` + ThreadListWidthPercent int `toml:"thread_list_width_percent"` +} + +type PathConfig struct { + Scroll bool `toml:"scroll"` + ScrollInterval configDuration `toml:"scroll_interval"` +} + +func defaultConfig() Config { + return Config{ + Theme: "dark", + RefreshInterval: configDuration{10 * time.Second}, + Limit: 50, + Endpoint: "https://api.github.com/graphql", + Display: DisplayConfig{ + FoldResolved: true, + ThreadListWidthPercent: 33, + }, + Paths: PathConfig{ + Scroll: false, + ScrollInterval: configDuration{350 * time.Millisecond}, + }, + } +} + +func configPath() (string, error) { + if path := os.Getenv("GH_THREADS_CONFIG"); path != "" { + return path, nil + } + if base := os.Getenv("XDG_CONFIG_HOME"); base != "" { + return filepath.Join(base, "gh-threads", "config.toml"), nil + } + base, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("find user config directory: %w", err) + } + preferred := filepath.Join(base, "gh-threads", "config.toml") + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("find home directory: %w", err) + } + fallback := filepath.Join(home, ".config", "gh-threads", "config.toml") + return existingConfigPath(preferred, fallback), nil +} + +func existingConfigPath(preferred, fallback string) string { + if _, err := os.Stat(preferred); err == nil || !errors.Is(err, os.ErrNotExist) { + return preferred + } + if _, err := os.Stat(fallback); err == nil { + return fallback + } + return preferred +} + +func loadConfig(path string, required bool) (Config, error) { + config := defaultConfig() + metadata, err := toml.DecodeFile(path, &config) + if err != nil { + if !required && errors.Is(err, os.ErrNotExist) { + return config, nil + } + return Config{}, fmt.Errorf("read config %s: %w", path, err) + } + if undecoded := metadata.Undecoded(); len(undecoded) > 0 { + keys := make([]string, len(undecoded)) + for i, key := range undecoded { + keys[i] = key.String() + } + return Config{}, fmt.Errorf("config %s contains unknown settings: %s", path, strings.Join(keys, ", ")) + } + return config, nil +} + +func validateConfig(config Config) error { + switch config.Theme { + case "dark", "light": + default: + return fmt.Errorf("theme must be dark or light") + } + if config.RefreshInterval.Duration < 2*time.Second { + return fmt.Errorf("refresh_interval must be at least 2s") + } + if config.Limit < 1 || config.Limit > 100 { + return fmt.Errorf("limit must be between 1 and 100") + } + if config.Paths.ScrollInterval.Duration < 50*time.Millisecond { + return fmt.Errorf("paths.scroll_interval must be at least 50ms") + } + if config.Display.ThreadListWidthPercent < 20 || config.Display.ThreadListWidthPercent > 60 { + return fmt.Errorf("display.thread_list_width_percent must be between 20 and 60") + } + if config.ShowAll && config.Repository == "" { + return fmt.Errorf("show_all requires repository") + } + return nil +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..0b71616 --- /dev/null +++ b/config_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLoadConfigUsesDefaultsWhenOptionalFileIsMissing(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing.toml") + got, err := loadConfig(path, false) + if err != nil { + t.Fatal(err) + } + want := defaultConfig() + if got.Theme != want.Theme || + got.RefreshInterval.Duration != want.RefreshInterval.Duration || + got.Paths.Scroll != want.Paths.Scroll || + got.Display.FoldResolved != want.Display.FoldResolved { + t.Fatalf("defaults = %#v, want %#v", got, want) + } +} + +func TestLoadConfigParsesUserSettings(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + content := ` +theme = "light" +refresh_interval = "25s" +repository = "owner/repo" +show_all = true +limit = 75 +endpoint = "https://github.example.com/api/graphql" + +[display] +fold_resolved = false +thread_list_width_percent = 45 + +[paths] +scroll = true +scroll_interval = "125ms" +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + got, err := loadConfig(path, true) + if err != nil { + t.Fatal(err) + } + if err := validateConfig(got); err != nil { + t.Fatal(err) + } + 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 { + t.Fatalf("config = %#v", got) + } +} + +func TestLoadConfigRejectsUnknownSettings(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte("refesh_interval = \"10s\"\n"), 0o600); err != nil { + t.Fatal(err) + } + _, err := loadConfig(path, true) + if err == nil || !strings.Contains(err.Error(), "refesh_interval") { + t.Fatalf("unknown setting error = %v", err) + } +} + +func TestConfigPathHonorsEnvironmentOverride(t *testing.T) { + t.Setenv("GH_THREADS_CONFIG", "/tmp/custom-gh-threads.toml") + got, err := configPath() + if err != nil { + t.Fatal(err) + } + if got != "/tmp/custom-gh-threads.toml" { + t.Fatalf("config path = %q", got) + } +} + +func TestExistingConfigPathFallsBackToDotConfig(t *testing.T) { + root := t.TempDir() + preferred := filepath.Join(root, "Library", "Application Support", "gh-threads", "config.toml") + fallback := filepath.Join(root, ".config", "gh-threads", "config.toml") + if err := os.MkdirAll(filepath.Dir(fallback), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fallback, []byte("theme = \"dark\"\n"), 0o600); err != nil { + t.Fatal(err) + } + if got := existingConfigPath(preferred, fallback); got != fallback { + t.Fatalf("config path = %q, want fallback %q", got, fallback) + } + + if err := os.MkdirAll(filepath.Dir(preferred), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(preferred, []byte("theme = \"light\"\n"), 0o600); err != nil { + t.Fatal(err) + } + if got := existingConfigPath(preferred, fallback); got != preferred { + t.Fatalf("config path = %q, want preferred %q", got, preferred) + } +} + +func TestConfigPathHonorsXDGConfigHome(t *testing.T) { + t.Setenv("GH_THREADS_CONFIG", "") + t.Setenv("XDG_CONFIG_HOME", "/tmp/xdg-config") + got, err := configPath() + if err != nil { + t.Fatal(err) + } + want := "/tmp/xdg-config/gh-threads/config.toml" + if got != want { + t.Fatalf("config path = %q, want %q", got, want) + } +} + +func TestValidateConfigRejectsUnsafeAnimationRate(t *testing.T) { + config := defaultConfig() + config.Paths.ScrollInterval.Duration = 10 * time.Millisecond + if err := validateConfig(config); err == nil { + t.Fatal("unsafe path scrolling interval was accepted") + } +} diff --git a/go.mod b/go.mod index 0ade1b1..cc6c206 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module git.pablu.de/Pablu/gh-threads go 1.24.0 require ( + github.com/BurntSushi/toml v1.5.0 github.com/alecthomas/chroma/v2 v2.20.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v1.0.0 diff --git a/go.sum b/go.sum index 88ff987..1b32fb3 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= diff --git a/highlight.go b/highlight.go index 0c7be49..dd461b5 100644 --- a/highlight.go +++ b/highlight.go @@ -13,6 +13,8 @@ import ( const reviewContextLines = 3 +var codeHighlightTheme = "github-dark" + var hunkHeaderPattern = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`) type highlightedDiffLine struct { @@ -223,7 +225,7 @@ func coordinateText(line int) string { func highlightedSource(lexer, source string) string { var highlighted bytes.Buffer - if err := quick.Highlight(&highlighted, source, lexer, "terminal16m", "github-dark"); err != nil { + if err := quick.Highlight(&highlighted, source, lexer, "terminal16m", codeHighlightTheme); err != nil { return source } return strings.TrimSuffix(highlighted.String(), "\n") diff --git a/main.go b/main.go index 33ea77b..d850fda 100644 --- a/main.go +++ b/main.go @@ -5,45 +5,99 @@ import ( "fmt" "os" "strings" - "time" tea "github.com/charmbracelet/bubbletea" ) func main() { + defaults := defaultConfig() + defaultConfigPath, err := configPath() + if err != nil { + exitf("configuration: %v", err) + } var ( - repo = flag.String("repo", os.Getenv("GH_REPO"), "optional GitHub repository filter as owner/name (or GH_REPO)") - poll = flag.Duration("poll", 10*time.Second, "refresh interval") - showAll = flag.Bool("all", false, "show all open PRs in --repo, not only PRs assigned to you") - limit = flag.Int("limit", 50, "maximum open PRs to load (1-100)") - endpoint = flag.String("endpoint", "https://api.github.com/graphql", "GitHub GraphQL endpoint") + configFile = flag.String("config", defaultConfigPath, "TOML configuration file") + repo = flag.String("repo", "", "optional GitHub repository filter as owner/name (or GH_REPO)") + poll = flag.Duration("poll", defaults.RefreshInterval.Duration, "refresh interval") + showAll = flag.Bool("all", defaults.ShowAll, "show all open PRs in --repo, not only PRs assigned to you") + limit = flag.Int("limit", defaults.Limit, "maximum open PRs to load (1-100)") + endpoint = flag.String("endpoint", defaults.Endpoint, "GitHub GraphQL endpoint") + theme = flag.String("theme", defaults.Theme, "color theme: dark or light") + foldResolved = flag.Bool("fold-resolved", defaults.Display.FoldResolved, "start resolved threads folded") + listWidth = flag.Int("thread-list-width", defaults.Display.ThreadListWidthPercent, "thread list width as terminal percentage (20-60)") + pathScroll = flag.Bool("path-scroll", defaults.Paths.Scroll, "scroll truncated paths") + pathScrollRate = flag.Duration("path-scroll-interval", defaults.Paths.ScrollInterval.Duration, "path scrolling interval") ) flag.Parse() + visited := map[string]bool{} + flag.Visit(func(item *flag.Flag) { visited[item.Name] = true }) + config, err := loadConfig(*configFile, visited["config"] || os.Getenv("GH_THREADS_CONFIG") != "") + if err != nil { + exitf("configuration: %v", err) + } + if visited["repo"] { + config.Repository = *repo + } else if envRepo := os.Getenv("GH_REPO"); envRepo != "" { + config.Repository = envRepo + } + if visited["poll"] { + config.RefreshInterval.Duration = *poll + } + if visited["all"] { + config.ShowAll = *showAll + } + if visited["limit"] { + config.Limit = *limit + } + if visited["endpoint"] { + config.Endpoint = *endpoint + } + if visited["theme"] { + config.Theme = *theme + } + if visited["fold-resolved"] { + config.Display.FoldResolved = *foldResolved + } + if visited["thread-list-width"] { + config.Display.ThreadListWidthPercent = *listWidth + } + if visited["path-scroll"] { + config.Paths.Scroll = *pathScroll + } + if visited["path-scroll-interval"] { + config.Paths.ScrollInterval.Duration = *pathScrollRate + } + if err := validateConfig(config); err != nil { + exitf("configuration: %v", err) + } + var owner, name string - if *repo != "" { - parts := strings.Split(*repo, "/") + if config.Repository != "" { + parts := strings.Split(config.Repository, "/") if len(parts) != 2 || parts[0] == "" || parts[1] == "" { exitf("--repo must be owner/name") } owner, name = parts[0], parts[1] } - if *showAll && owner == "" { - exitf("--all requires --repo") + if err := applyTheme(config.Theme); err != nil { + exitf("configuration: %v", err) } - if *limit < 1 || *limit > 100 { - exitf("--limit must be between 1 and 100") - } - if *poll < 2*time.Second { - exitf("--poll must be at least 2s") - } - token, err := resolveToken(*endpoint) + token, err := resolveToken(config.Endpoint) if err != nil { exitf("authenticate: %v", err) } - client := NewGitHubClient(*endpoint, token) - app := NewApp(client, owner, name, *showAll, *limit, *poll) + client := NewGitHubClient(config.Endpoint, token) + app := NewAppWithSettings( + client, owner, name, config.ShowAll, config.Limit, config.RefreshInterval.Duration, + AppSettings{ + FoldResolved: config.Display.FoldResolved, + ThreadListWidthPercent: config.Display.ThreadListWidthPercent, + PathScroll: config.Paths.Scroll, + PathScrollInterval: config.Paths.ScrollInterval.Duration, + }, + ) if _, err := tea.NewProgram(app, tea.WithAltScreen()).Run(); err != nil { exitf("run TUI: %v", err) } diff --git a/markdown.go b/markdown.go index afa9f3f..d6e314e 100644 --- a/markdown.go +++ b/markdown.go @@ -11,6 +11,7 @@ import ( var commentMarkdownRenderers sync.Map var quoteRailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")) +var markdownStyleName = "dark" func renderCommentMarkdown(markdown string, width int) []string { if strings.TrimSpace(markdown) == "" { @@ -79,6 +80,9 @@ func commentMarkdownRenderer(width int) (*glamour.TermRenderer, error) { return cached.(*glamour.TermRenderer), nil } style := styles.DarkStyleConfig + if markdownStyleName == "light" { + style = styles.LightStyleConfig + } zero := uint(0) style.Document.Margin = &zero style.Code.Prefix = "" diff --git a/theme.go b/theme.go new file mode 100644 index 0000000..4ff6905 --- /dev/null +++ b/theme.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +func applyTheme(name string) error { + switch name { + case "dark": + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F")) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")) + activeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(lipgloss.Color("#3B4261")) + okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#67C587")) + warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B")) + badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75")) + paneInactiveColor = lipgloss.Color("#50566F") + paneActiveColor = lipgloss.Color("#F0B72F") + authorPalette = []lipgloss.Color{ + "#61AFEF", "#C678DD", "#56B6C2", "#E5C07B", + "#E06C75", "#98C379", "#D19A66", "#7FC8FF", + } + codeHighlightTheme = "github-dark" + markdownStyleName = "dark" + quoteRailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")) + selectedLineBackground = "\x1b[48;5;24m" + suggestionRemoveBackground = "\x1b[48;5;52m" + suggestionAddBackground = "\x1b[48;5;22m" + changedRemoveBackground = "\x1b[48;2;55;0;0m" + changedAddBackground = "\x1b[48;2;0;55;0m" + case "light": + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#9A6700")) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#656D76")) + activeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(lipgloss.Color("#0969DA")) + okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#1A7F37")) + warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#9A6700")) + badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#CF222E")) + paneInactiveColor = lipgloss.Color("#8C959F") + paneActiveColor = lipgloss.Color("#0969DA") + authorPalette = []lipgloss.Color{ + "#0550AE", "#8250DF", "#0A706F", "#9A6700", + "#CF222E", "#116329", "#953800", "#0969DA", + } + codeHighlightTheme = "github" + markdownStyleName = "light" + quoteRailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#656D76")) + selectedLineBackground = "\x1b[48;5;153m" + suggestionRemoveBackground = "\x1b[48;5;224m" + suggestionAddBackground = "\x1b[48;5;194m" + changedRemoveBackground = "\x1b[48;2;255;170;170m" + changedAddBackground = "\x1b[48;2;170;230;170m" + default: + return fmt.Errorf("unknown theme %q", name) + } + commentMarkdownRenderers.Clear() + return nil +} diff --git a/theme_test.go b/theme_test.go new file mode 100644 index 0000000..e2fe82a --- /dev/null +++ b/theme_test.go @@ -0,0 +1,20 @@ +package main + +import "testing" + +func TestApplyThemeChangesAllRenderingThemes(t *testing.T) { + defer func() { + if err := applyTheme("dark"); err != nil { + t.Fatal(err) + } + }() + if err := applyTheme("light"); err != nil { + t.Fatal(err) + } + if markdownStyleName != "light" || codeHighlightTheme != "github" { + t.Fatalf("light theme did not propagate: markdown=%q code=%q", markdownStyleName, codeHighlightTheme) + } + if err := applyTheme("unknown"); err == nil { + t.Fatal("unknown theme was accepted") + } +} diff --git a/tui.go b/tui.go index 7eb98ee..c81164c 100644 --- a/tui.go +++ b/tui.go @@ -28,6 +28,7 @@ const ( ) type tickMsg time.Time +type pathTickMsg time.Time type prsLoadedMsg struct { prs []PullRequest err error @@ -66,23 +67,65 @@ type App struct { searchOrigin int helpVisible bool helpScroll int + + foldResolved bool + threadListWidthPercent int + pathScroll bool + pathScrollInterval time.Duration + pathScrollStep int +} + +type AppSettings struct { + FoldResolved bool + ThreadListWidthPercent int + PathScroll bool + PathScrollInterval time.Duration +} + +func defaultAppSettings() AppSettings { + return AppSettings{ + FoldResolved: true, + ThreadListWidthPercent: 33, + PathScrollInterval: 350 * time.Millisecond, + } } func NewApp(service GitHubService, owner, repo string, showAll bool, limit int, poll time.Duration) App { + return NewAppWithSettings(service, owner, repo, showAll, limit, poll, defaultAppSettings()) +} + +func NewAppWithSettings( + service GitHubService, + owner, repo string, + showAll bool, + limit int, + poll time.Duration, + settings AppSettings, +) App { return App{ service: service, owner: owner, repo: repo, showAll: showAll, limit: limit, poll: poll, folded: make(map[string]bool), loading: true, + foldResolved: settings.FoldResolved, threadListWidthPercent: settings.ThreadListWidthPercent, + pathScroll: settings.PathScroll, pathScrollInterval: settings.PathScrollInterval, } } func (m App) Init() tea.Cmd { - return tea.Batch(m.loadPRs(), m.nextTick()) + commands := []tea.Cmd{m.loadPRs(), m.nextTick()} + if m.pathScroll { + commands = append(commands, m.nextPathTick()) + } + return tea.Batch(commands...) } func (m App) nextTick() tea.Cmd { return tea.Tick(m.poll, func(t time.Time) tea.Msg { return tickMsg(t) }) } +func (m App) nextPathTick() tea.Cmd { + return tea.Tick(m.pathScrollInterval, func(t time.Time) tea.Msg { return pathTickMsg(t) }) +} + func (m App) loadPRs() tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) @@ -117,6 +160,14 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(m.loadPRs(), m.nextTick()) } return m, m.nextTick() + case pathTickMsg: + if m.pathScroll && m.screen == threadScreen { + m.pathScrollStep++ + } + if m.pathScroll { + return m, m.nextPathTick() + } + return m, nil case prsLoadedMsg: m.loading = false if msg.err != nil { @@ -158,7 +209,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.scroll = 0 } for _, thread := range m.details.Threads { - if _, set := m.folded[thread.ID]; !set && thread.IsResolved { + if _, set := m.folded[thread.ID]; !set && thread.IsResolved && m.foldResolved { m.folded[thread.ID] = true } } @@ -296,6 +347,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.details = PRDetails{PullRequest: m.prs[m.prIndex]} m.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil m.searching, m.searchQuery = false, "" + m.pathScrollStep = 0 return m, m.loadDetails(m.details.PullRequest) } if m.screen == threadScreen { @@ -312,6 +364,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.details = PRDetails{PullRequest: m.prs[m.prIndex]} m.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil m.searching, m.searchQuery = false, "" + m.pathScrollStep = 0 return m, m.loadDetails(m.details.PullRequest) } if m.screen == threadScreen && len(m.details.Threads) > 0 { @@ -430,10 +483,18 @@ func (m App) detailPaneSize() (int, int) { if m.width < 70 || m.listHidden { return max(3, m.width), height } - leftWidth := clamp(m.width/3, 30, 48) + leftWidth := m.threadListWidth() return max(20, m.width-leftWidth-1), height } +func (m App) threadListWidth() int { + percent := m.threadListWidthPercent + if percent == 0 { + percent = 33 + } + return clamp(m.width*percent/100, 30, max(30, m.width-20)) +} + func (m App) detailViewportHeight() int { _, height := m.detailPaneSize() return max(1, height-2) @@ -533,18 +594,26 @@ func (m App) viewHelp() string { } var ( - titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F")) - dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")) - activeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(lipgloss.Color("#3B4261")) - okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#67C587")) - warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B")) - badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75")) + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F")) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")) + activeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(lipgloss.Color("#3B4261")) + okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#67C587")) + warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B")) + badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75")) + paneInactiveColor = lipgloss.Color("#50566F") + paneActiveColor = lipgloss.Color("#F0B72F") + + selectedLineBackground = "\x1b[48;5;24m" + suggestionRemoveBackground = "\x1b[48;5;52m" + suggestionAddBackground = "\x1b[48;5;22m" + changedRemoveBackground = "\x1b[48;2;55;0;0m" + changedAddBackground = "\x1b[48;2;0;55;0m" ) func paneStyle(active bool) lipgloss.Style { - color := lipgloss.Color("#50566F") + color := paneInactiveColor if active { - color = lipgloss.Color("#F0B72F") + color = paneActiveColor } return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(color) } @@ -636,7 +705,7 @@ func (m App) viewThreads() string { } else if m.listHidden { body = m.threadDetail(m.width, contentHeight) } else { - leftWidth := clamp(m.width/3, 30, 48) + leftWidth := m.threadListWidth() rightWidth := max(20, m.width-leftWidth-1) left := m.threadList(leftWidth, contentHeight) right := m.threadDetail(rightWidth, contentHeight) @@ -684,7 +753,11 @@ func (m App) threadList(width, height int) string { } suffix := fmt.Sprintf(":%d · %d", thread.Line, len(thread.Comments)) pathWidth := max(4, innerWidth-lipgloss.Width(suffix)-2) - line := icon + " " + pad(truncatePath(thread.Path, pathWidth), pathWidth) + suffix + path := truncatePath(thread.Path, pathWidth) + if m.pathScroll { + path = scrollingPath(thread.Path, pathWidth, m.pathScrollStep) + } + line := icon + " " + pad(path, pathWidth) + suffix line = ansi.Truncate(line, innerWidth, "") if thread.IsResolved { line = dimStyle.Render(line) @@ -861,9 +934,9 @@ func highlightCodeRange(code string, changed codeRange, change byte) string { return code } - background := "\x1b[48;2;55;0;0m" + background := changedRemoveBackground if change == '+' { - background = "\x1b[48;2;0;55;0m" + background = changedAddBackground } const reset = "\x1b[0m" before := ansi.Cut(code, 0, start) @@ -953,19 +1026,17 @@ func shortOID(oid string) string { } func selectedBackground(line string, width int) string { - const ( - background = "\x1b[48;5;24m" - reset = "\x1b[0m" - ) + const reset = "\x1b[0m" + background := selectedLineBackground line = pad(ansi.Truncate(line, width, ""), width) line = strings.ReplaceAll(line, reset, reset+background) return background + line + reset } func suggestionHighlight(gutter, code string, width int, change byte) string { - background := "\x1b[48;5;52m" + background := suggestionRemoveBackground if change == '+' { - background = "\x1b[48;5;22m" + background = suggestionAddBackground } const reset = "\x1b[0m" gutter = ansi.Truncate(gutter, width, "") @@ -1138,6 +1209,41 @@ func truncatePath(path string, width int) string { return "…" + ansi.Cut(path, pathWidth-width+1, pathWidth) } +func scrollingPath(path string, width, step int) string { + const pauseSteps = 4 + if width <= 0 { + return "" + } + pathWidth := ansi.StringWidth(path) + if pathWidth <= width { + return path + } + if width == 1 { + return "…" + } + + visibleWidth := width - 1 + maxOffset := pathWidth - visibleWidth + cycle := pauseSteps + maxOffset + pauseSteps + phase := step % cycle + offset := 0 + switch { + case phase < pauseSteps: + offset = 0 + case phase < pauseSteps+maxOffset: + offset = phase - pauseSteps + default: + offset = maxOffset + } + if offset == 0 { + return ansi.Cut(path, 0, visibleWidth) + "…" + } + if offset == maxOffset { + return "…" + ansi.Cut(path, pathWidth-visibleWidth, pathWidth) + } + return "…" + ansi.Cut(path, offset, offset+width-2) + "…" +} + func fuzzyPathScore(path, query string) (int, bool) { candidate := []rune(strings.ToLower(path)) terms := strings.Fields(strings.ToLower(query)) diff --git a/tui_test.go b/tui_test.go index 7e74640..c1a0b30 100644 --- a/tui_test.go +++ b/tui_test.go @@ -44,6 +44,20 @@ func TestResolvedThreadsStartFolded(t *testing.T) { } } +func TestResolvedThreadFoldingCanBeDisabled(t *testing.T) { + settings := defaultAppSettings() + settings.FoldResolved = false + m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings) + m.details = PRDetails{PullRequest: PullRequest{Number: 7}} + updated, _ := m.Update(detailsLoadedMsg{number: 7, details: PRDetails{ + PullRequest: PullRequest{Number: 7}, + Threads: []ReviewThread{{ID: "done", IsResolved: true}}, + }}) + if updated.(App).folded["done"] { + t.Fatal("resolved thread was folded despite configuration") + } +} + func TestResolvedThreadIconTakesPrecedenceOverOutdated(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.details = PRDetails{Threads: []ReviewThread{{ @@ -181,6 +195,36 @@ func TestTruncatePathPreservesFilename(t *testing.T) { } } +func TestConfiguredPathScrollingAdvances(t *testing.T) { + settings := defaultAppSettings() + settings.PathScroll = true + settings.PathScrollInterval = 100 * time.Millisecond + m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings) + m.screen = threadScreen + updated, _ := m.Update(pathTickMsg(time.Now())) + if got := updated.(App).pathScrollStep; got != 1 { + t.Fatalf("path scroll step = %d, want 1", got) + } + + path := "internal/review/threads/important_filename.go" + if beginning := scrollingPath(path, 18, 0); !strings.HasPrefix(beginning, "internal/") { + t.Fatalf("scroll beginning = %q", beginning) + } + if end := scrollingPath(path, 18, 1000); ansi.StringWidth(end) != 18 { + t.Fatalf("scroll output has width %d", ansi.StringWidth(end)) + } +} + +func TestConfiguredThreadListWidth(t *testing.T) { + settings := defaultAppSettings() + settings.ThreadListWidthPercent = 50 + m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings) + m.width = 120 + if got := m.threadListWidth(); got != 60 { + t.Fatalf("thread list width = %d, want 60", got) + } +} + func TestFuzzyFileSearchRanksAndFiltersPaths(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.searchQuery = "svcusr"