Add configuration
This commit is contained in:
33
README.md
33
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 |
|
||||
|
||||
136
config.go
Normal file
136
config.go
Normal 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
128
config_test.go
Normal 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
1
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
@@ -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
92
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)
|
||||
}
|
||||
|
||||
@@ -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
58
theme.go
Normal 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
20
theme_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
136
tui.go
136
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)
|
||||
@@ -539,12 +600,20 @@ var (
|
||||
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))
|
||||
|
||||
44
tui_test.go
44
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"
|
||||
|
||||
Reference in New Issue
Block a user