286 lines
9.1 KiB
Go
286 lines
9.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"`
|
|
Mouse bool `toml:"mouse"`
|
|
Mascot bool `toml:"mascot"`
|
|
MascotExpressive bool `toml:"mascot_expressive"`
|
|
MascotAnimated bool `toml:"mascot_animated"`
|
|
Display DisplayConfig `toml:"display"`
|
|
Paths PathConfig `toml:"paths"`
|
|
Threads ThreadConfig `toml:"threads"`
|
|
Cache CacheConfig `toml:"cache"`
|
|
Editing EditingConfig `toml:"editing"`
|
|
CustomTheme CustomThemeConfig `toml:"custom_theme"`
|
|
KeyBindings KeyBindings `toml:"keybindings"`
|
|
AI AIConfig `toml:"ai"`
|
|
}
|
|
|
|
type CustomThemeConfig struct {
|
|
Base string `toml:"base"`
|
|
Mode string `toml:"mode"`
|
|
Title string `toml:"title"`
|
|
Dim string `toml:"dim"`
|
|
Text string `toml:"text"`
|
|
ActiveForeground string `toml:"active_foreground"`
|
|
ActiveBackground string `toml:"active_background"`
|
|
Success string `toml:"success"`
|
|
Warning string `toml:"warning"`
|
|
Error string `toml:"error"`
|
|
EditorForeground string `toml:"editor_foreground"`
|
|
EditorBackground string `toml:"editor_background"`
|
|
PaneInactive string `toml:"pane_inactive"`
|
|
PaneActive string `toml:"pane_active"`
|
|
Quote string `toml:"quote"`
|
|
SelectionBackground string `toml:"selection_background"`
|
|
SuggestionRemoveBackground string `toml:"suggestion_remove_background"`
|
|
SuggestionAddBackground string `toml:"suggestion_add_background"`
|
|
ChangedRemoveBackground string `toml:"changed_remove_background"`
|
|
ChangedAddBackground string `toml:"changed_add_background"`
|
|
AuthorPalette []string `toml:"author_palette"`
|
|
SyntaxTheme string `toml:"syntax_theme"`
|
|
}
|
|
|
|
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"`
|
|
ViewerLabel string `toml:"viewer_label"`
|
|
}
|
|
|
|
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"`
|
|
MaxEntries int `toml:"max_entries"`
|
|
}
|
|
|
|
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",
|
|
Mouse: false,
|
|
Mascot: false,
|
|
MascotExpressive: false,
|
|
MascotAnimated: false,
|
|
Display: DisplayConfig{
|
|
FoldResolved: true,
|
|
ThreadListWidthPercent: 33,
|
|
DashboardMode: "hotkey",
|
|
CompactReviews: true,
|
|
ViewerLabel: "login",
|
|
},
|
|
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}, MaxEntries: 200,
|
|
},
|
|
Editing: EditingConfig{Mode: "vim"},
|
|
AI: defaultAIConfig(),
|
|
KeyBindings: defaultKeyBindings(),
|
|
}
|
|
}
|
|
|
|
func configPath() (string, error) {
|
|
if path := os.Getenv("DIPLE_CONFIG"); path != "" {
|
|
return path, nil
|
|
}
|
|
// Preserve the old override during the rename so existing scripts do not
|
|
// silently start with a fresh configuration.
|
|
if path := os.Getenv("GH_THREADS_CONFIG"); path != "" {
|
|
return path, nil
|
|
}
|
|
if base := os.Getenv("XDG_CONFIG_HOME"); base != "" {
|
|
return firstExistingOrDefault(
|
|
filepath.Join(base, "diple", "config.toml"),
|
|
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, "diple", "config.toml")
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("find home directory: %w", err)
|
|
}
|
|
dotConfig := filepath.Join(home, ".config", "diple", "config.toml")
|
|
legacyPreferred := filepath.Join(base, "gh-threads", "config.toml")
|
|
legacyDotConfig := filepath.Join(home, ".config", "gh-threads", "config.toml")
|
|
return firstExistingOrDefault(
|
|
preferred, dotConfig, legacyPreferred, legacyDotConfig,
|
|
), nil
|
|
}
|
|
|
|
func existingConfigPath(preferred, fallback string) string {
|
|
return firstExistingOrDefault(preferred, fallback)
|
|
}
|
|
|
|
func firstExistingOrDefault(preferred string, alternatives ...string) string {
|
|
for _, candidate := range append([]string{preferred}, alternatives...) {
|
|
if _, err := os.Stat(candidate); err == nil || !errors.Is(err, os.ErrNotExist) {
|
|
return candidate
|
|
}
|
|
}
|
|
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 {
|
|
if _, err := resolveThemePalette(config.Theme, config.CustomTheme); err != nil {
|
|
return err
|
|
}
|
|
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")
|
|
}
|
|
switch config.Display.ViewerLabel {
|
|
case "login", "you":
|
|
default:
|
|
return fmt.Errorf("display.viewer_label must be login or you")
|
|
}
|
|
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")
|
|
}
|
|
if config.Cache.MaxEntries < 10 || config.Cache.MaxEntries > 10000 {
|
|
return fmt.Errorf("cache.max_entries must be between 10 and 10000")
|
|
}
|
|
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
|
|
}
|
|
if err := validateAIConfig(config.AI); 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 firstExistingOrDefault(
|
|
filepath.Join(base, "diple"),
|
|
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
|
|
}
|