Add configuration

This commit is contained in:
2026-07-28 09:35:15 +02:00
parent 547cd123da
commit 80f24bcfa8
12 changed files with 629 additions and 41 deletions

View File

@@ -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 |

136
config.go Normal file
View File

@@ -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
}

128
config_test.go Normal file
View File

@@ -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")
}
}

1
go.mod
View File

@@ -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

2
go.sum
View File

@@ -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=

View File

@@ -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")

92
main.go
View File

@@ -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)
}

View File

@@ -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 = ""

58
theme.go Normal file
View File

@@ -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
}

20
theme_test.go Normal file
View File

@@ -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")
}
}

148
tui.go
View File

@@ -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))

View File

@@ -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"