218 lines
6.1 KiB
Go
218 lines
6.1 KiB
Go
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"`
|
|
Threads ThreadConfig `toml:"threads"`
|
|
Cache CacheConfig `toml:"cache"`
|
|
Editing EditingConfig `toml:"editing"`
|
|
KeyBindings KeyBindings `toml:"keybindings"`
|
|
}
|
|
|
|
type DisplayConfig struct {
|
|
FoldResolved bool `toml:"fold_resolved"`
|
|
ThreadListWidthPercent int `toml:"thread_list_width_percent"`
|
|
DashboardMode string `toml:"dashboard_mode"`
|
|
CompactReviews bool `toml:"compact_reviews"`
|
|
}
|
|
|
|
type PathConfig struct {
|
|
Scroll bool `toml:"scroll"`
|
|
ScrollInterval configDuration `toml:"scroll_interval"`
|
|
}
|
|
|
|
type ThreadConfig struct {
|
|
StatusOrder []string `toml:"status_order"`
|
|
WithinStatus string `toml:"within_status"`
|
|
}
|
|
|
|
type CacheConfig struct {
|
|
Enabled bool `toml:"enabled"`
|
|
MaxAge configDuration `toml:"max_age"`
|
|
Directory string `toml:"directory"`
|
|
}
|
|
|
|
type EditingConfig struct {
|
|
Mode string `toml:"mode"`
|
|
}
|
|
|
|
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,
|
|
DashboardMode: "hotkey",
|
|
CompactReviews: true,
|
|
},
|
|
Paths: PathConfig{
|
|
Scroll: false,
|
|
ScrollInterval: configDuration{350 * time.Millisecond},
|
|
},
|
|
Threads: ThreadConfig{
|
|
StatusOrder: []string{"unresolved", "outdated", "resolved"},
|
|
WithinStatus: "file",
|
|
},
|
|
Cache: CacheConfig{Enabled: true, MaxAge: configDuration{7 * 24 * time.Hour}},
|
|
Editing: EditingConfig{Mode: "vim"},
|
|
KeyBindings: defaultKeyBindings(),
|
|
}
|
|
}
|
|
|
|
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", "no-color", "high-contrast":
|
|
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 > 1000 {
|
|
return fmt.Errorf("limit must be between 1 and 1000")
|
|
}
|
|
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")
|
|
}
|
|
switch config.Display.DashboardMode {
|
|
case "intermediate", "hotkey":
|
|
default:
|
|
return fmt.Errorf("display.dashboard_mode must be intermediate or hotkey")
|
|
}
|
|
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")
|
|
}
|
|
if config.Cache.MaxAge.Duration < 0 {
|
|
return fmt.Errorf("cache.max_age must not be negative")
|
|
}
|
|
switch config.Editing.Mode {
|
|
case "standard", "vim":
|
|
default:
|
|
return fmt.Errorf("editing.mode must be standard or vim")
|
|
}
|
|
if err := validateKeyBindings(config.KeyBindings); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func defaultCacheDir() (string, error) {
|
|
base, err := os.UserCacheDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("find user cache directory: %w", err)
|
|
}
|
|
return filepath.Join(base, "gh-threads"), 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
|
|
}
|