feat: add more refresh duration options & formats

This commit is contained in:
2025-12-14 17:54:13 +02:00
parent 7be6a03d6d
commit f15b9e2559
6 changed files with 273 additions and 23 deletions

View File

@@ -76,6 +76,18 @@ watchr "ps aux"
# Refresh every 2 seconds
watchr -r 2 "docker ps"
# Refresh every 500 milliseconds
watchr -r 500ms "date"
# Refresh every 1.5 seconds
watchr -r 1.5s "kubectl get pods"
# Refresh every 5 minutes
watchr -r 5m "df -h"
# Refresh every hour
watchr -r 1h "curl -s https://api.example.com/status"
# Watch file changes
watchr -r 5 "find . -name '*.go' -mmin -1"
```
@@ -90,7 +102,7 @@ Options:
-v, --version Show version
-c, --config string Load config from specified path
-C, --show-config Show loaded configuration and exit
-r, --refresh int Auto-refresh interval in seconds (0 = disabled)
-r, --refresh string Auto-refresh interval (e.g., 1, 1.5, 500ms, 2s, 5m, 1h; default unit: seconds, 0 = disabled)
-p, --prompt string Prompt string (default "watchr> ")
-s, --shell string Shell to use for executing commands (default "sh")
-n, --no-line-numbers Disable line numbers
@@ -124,7 +136,7 @@ preview-position: right
line-numbers: true
line-width: 4
prompt: "> "
refresh: 0
refresh: 0 # disabled; or use: 2, 1.5, "500ms", "2s", "5m", "1h"
interactive: false
```
@@ -136,7 +148,7 @@ preview-position = "right"
line-numbers = true
line-width = 4
prompt = "> "
refresh = 0
refresh = 0 # disabled; or use: 2, 1.5, "500ms", "2s", "5m", "1h"
interactive = false
```
@@ -154,6 +166,10 @@ interactive = false
}
```
The `refresh` option accepts:
- Numbers: `2` or `1.5` (interpreted as seconds)
- Explicit units: `"500ms"`, `"2s"`, `"5m"`, `"1h"`
### Priority Order
Configuration values are applied in this order (later sources override earlier ones):

View File

@@ -4,7 +4,10 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"strconv"
"time"
"github.com/spf13/pflag"
"github.com/spf13/viper"
@@ -30,7 +33,7 @@ func setDefaults() {
viper.SetDefault(KeyLineNumbers, true)
viper.SetDefault(KeyLineWidth, 6)
viper.SetDefault(KeyPrompt, "watchr> ")
viper.SetDefault(KeyRefresh, 0)
viper.SetDefault(KeyRefresh, "0")
viper.SetDefault(KeyInteractive, false)
}
@@ -129,7 +132,7 @@ func PrintConfig() {
fmt.Printf(" %-20s %v\n", KeyLineNumbers+":", GetBool(KeyLineNumbers))
fmt.Printf(" %-20s %d\n", KeyLineWidth+":", GetInt(KeyLineWidth))
fmt.Printf(" %-20s %q\n", KeyPrompt+":", GetString(KeyPrompt))
fmt.Printf(" %-20s %d\n", KeyRefresh+":", GetInt(KeyRefresh))
fmt.Printf(" %-20s %s\n", KeyRefresh+":", GetString(KeyRefresh))
fmt.Printf(" %-20s %v\n", KeyInteractive+":", GetBool(KeyInteractive))
}
@@ -149,3 +152,57 @@ func getConfigDir() string {
return ""
}
}
// durationRegex matches duration strings like "1", "1.5", "500ms", "1s", "1.5s", "5m", "1h"
var durationRegex = regexp.MustCompile(`^(\d+(?:\.\d+)?)(ms|s|m|h)?$`)
// ParseDuration parses a duration string into a time.Duration.
// Supported formats:
// - "1", "1.5" - interpreted as seconds (default unit)
// - "1s", "1.5s" - explicit seconds
// - "500ms", "1500ms" - explicit milliseconds
// - "5m", "1.5m" - explicit minutes
// - "1h", "0.5h" - explicit hours
//
// Returns 0 if the input is empty or "0".
func ParseDuration(s string) (time.Duration, error) {
if s == "" || s == "0" {
return 0, nil
}
matches := durationRegex.FindStringSubmatch(s)
if matches == nil {
return 0, fmt.Errorf("invalid duration format: %q (expected number, Xms, Xs, Xm, or Xh)", s)
}
value, err := strconv.ParseFloat(matches[1], 64)
if err != nil {
return 0, fmt.Errorf("invalid duration value: %q", matches[1])
}
unit := matches[2]
switch unit {
case "ms":
return time.Duration(value * float64(time.Millisecond)), nil
case "s", "":
// Default to seconds
return time.Duration(value * float64(time.Second)), nil
case "m":
return time.Duration(value * float64(time.Minute)), nil
case "h":
return time.Duration(value * float64(time.Hour)), nil
default:
return 0, fmt.Errorf("invalid duration unit: %q", unit)
}
}
// GetDuration returns a duration config value by parsing the string value.
// Returns 0 if parsing fails or value is empty.
func GetDuration(key string) time.Duration {
s := viper.GetString(key)
d, err := ParseDuration(s)
if err != nil {
return 0
}
return d
}

View File

@@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"testing"
"time"
"github.com/spf13/pflag"
"github.com/spf13/viper"
@@ -70,8 +71,8 @@ func TestInit(t *testing.T) {
t.Errorf("expected default prompt 'watchr> ', got %q", got)
}
if got := viper.GetInt(KeyRefresh); got != 0 {
t.Errorf("expected default refresh 0, got %d", got)
if got := viper.GetString(KeyRefresh); got != "0" {
t.Errorf("expected default refresh '0', got %q", got)
}
}
@@ -131,7 +132,7 @@ func TestBindFlags(t *testing.T) {
flags.String("preview-position", "bottom", "")
flags.Int("line-width", 6, "")
flags.String("prompt", "watchr> ", "")
flags.Int("refresh", 0, "")
flags.String("refresh", "0", "")
flags.Bool("no-line-numbers", false, "")
// Parse with custom values
@@ -204,8 +205,8 @@ refresh: 5
t.Errorf("expected prompt 'test> ' from config file, got %q", got)
}
if got := GetInt(KeyRefresh); got != 5 {
t.Errorf("expected refresh 5 from config file, got %d", got)
if got := GetDuration(KeyRefresh); got != 5*time.Second {
t.Errorf("expected refresh 5s from config file, got %v", got)
}
// ConfigFileUsed should return the path
@@ -237,7 +238,7 @@ preview-size: "60%"
flags.String("preview-position", "bottom", "")
flags.Int("line-width", 6, "")
flags.String("prompt", "watchr> ", "")
flags.Int("refresh", 0, "")
flags.String("refresh", "0", "")
flags.Bool("no-line-numbers", false, "")
// Override shell via flag
@@ -322,8 +323,8 @@ refresh: 10
t.Errorf("expected prompt 'custom> ', got %q", got)
}
if got := GetInt(KeyRefresh); got != 10 {
t.Errorf("expected refresh 10, got %d", got)
if got := GetDuration(KeyRefresh); got != 10*time.Second {
t.Errorf("expected refresh 10s, got %v", got)
}
// ConfigFileUsed should return the specified path
@@ -434,3 +435,178 @@ func TestInitWithFileDefaults(t *testing.T) {
t.Errorf("expected default line-width 6, got %d", got)
}
}
func TestParseDuration(t *testing.T) {
tests := []struct {
input string
expected time.Duration
wantErr bool
}{
// Empty and zero values
{"", 0, false},
{"0", 0, false},
// Plain numbers (default to seconds)
{"1", 1 * time.Second, false},
{"5", 5 * time.Second, false},
{"60", 60 * time.Second, false},
// Decimal seconds
{"0.5", 500 * time.Millisecond, false},
{"1.5", 1500 * time.Millisecond, false},
{"2.25", 2250 * time.Millisecond, false},
{"0.1", 100 * time.Millisecond, false},
{"0.001", 1 * time.Millisecond, false},
// Explicit seconds suffix
{"1s", 1 * time.Second, false},
{"5s", 5 * time.Second, false},
{"0.5s", 500 * time.Millisecond, false},
{"1.5s", 1500 * time.Millisecond, false},
// Milliseconds suffix
{"100ms", 100 * time.Millisecond, false},
{"500ms", 500 * time.Millisecond, false},
{"1000ms", 1000 * time.Millisecond, false},
{"1500ms", 1500 * time.Millisecond, false},
{"50.5ms", 50500 * time.Microsecond, false},
// Minutes suffix
{"1m", 1 * time.Minute, false},
{"5m", 5 * time.Minute, false},
{"0.5m", 30 * time.Second, false},
{"1.5m", 90 * time.Second, false},
// Hours suffix
{"1h", 1 * time.Hour, false},
{"2h", 2 * time.Hour, false},
{"0.5h", 30 * time.Minute, false},
{"1.5h", 90 * time.Minute, false},
// Invalid formats
{"abc", 0, true},
{"1d", 0, true}, // days not supported
{"1w", 0, true}, // weeks not supported
{"-1", 0, true}, // negative not supported
{"-1s", 0, true}, // negative not supported
{"1.2.3", 0, true},
{"s", 0, true},
{"ms", 0, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := ParseDuration(tt.input)
if tt.wantErr {
if err == nil {
t.Errorf("ParseDuration(%q) expected error, got nil", tt.input)
}
return
}
if err != nil {
t.Errorf("ParseDuration(%q) unexpected error: %v", tt.input, err)
return
}
if got != tt.expected {
t.Errorf("ParseDuration(%q) = %v, want %v", tt.input, got, tt.expected)
}
})
}
}
func TestGetDuration(t *testing.T) {
_, cleanup := isolateConfig(t)
defer cleanup()
Init()
// Test default value
if got := GetDuration(KeyRefresh); got != 0 {
t.Errorf("expected default refresh 0, got %v", got)
}
// Test with seconds value
viper.Set(KeyRefresh, "5")
if got := GetDuration(KeyRefresh); got != 5*time.Second {
t.Errorf("expected refresh 5s, got %v", got)
}
// Test with decimal value
viper.Set(KeyRefresh, "0.5")
if got := GetDuration(KeyRefresh); got != 500*time.Millisecond {
t.Errorf("expected refresh 500ms, got %v", got)
}
// Test with explicit seconds suffix
viper.Set(KeyRefresh, "2s")
if got := GetDuration(KeyRefresh); got != 2*time.Second {
t.Errorf("expected refresh 2s, got %v", got)
}
// Test with milliseconds
viper.Set(KeyRefresh, "250ms")
if got := GetDuration(KeyRefresh); got != 250*time.Millisecond {
t.Errorf("expected refresh 250ms, got %v", got)
}
// Test with invalid value (should return 0)
viper.Set(KeyRefresh, "invalid")
if got := GetDuration(KeyRefresh); got != 0 {
t.Errorf("expected refresh 0 for invalid value, got %v", got)
}
}
func TestRefreshDurationFromConfigFile(t *testing.T) {
tmpDir, cleanup := isolateConfig(t)
defer cleanup()
tests := []struct {
name string
yaml string
expected time.Duration
}{
{
name: "integer seconds",
yaml: "refresh: 5\n",
expected: 5 * time.Second,
},
{
name: "decimal seconds",
yaml: "refresh: 0.5\n",
expected: 500 * time.Millisecond,
},
{
name: "string with s suffix",
yaml: "refresh: \"2s\"\n",
expected: 2 * time.Second,
},
{
name: "string with ms suffix",
yaml: "refresh: \"500ms\"\n",
expected: 500 * time.Millisecond,
},
{
name: "string decimal with s suffix",
yaml: "refresh: \"1.5s\"\n",
expected: 1500 * time.Millisecond,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resetViper()
configPath := filepath.Join(tmpDir, "watchr.yaml")
if err := os.WriteFile(configPath, []byte(tt.yaml), 0644); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
Init()
got := GetDuration(KeyRefresh)
if got != tt.expected {
t.Errorf("GetDuration(KeyRefresh) = %v, want %v", got, tt.expected)
}
})
}
}

View File

@@ -33,7 +33,7 @@ type Config struct {
ShowLineNums bool
LineNumWidth int
Prompt string
RefreshSeconds int
RefreshInterval time.Duration
Interactive bool
}
@@ -213,7 +213,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// If auto-refresh is enabled, schedule the next run
if m.config.RefreshSeconds > 0 {
if m.config.RefreshInterval > 0 {
return m, m.tickCmd()
}
return m, nil
@@ -223,7 +223,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.streamTickCmd()
case tickMsg:
if m.config.RefreshSeconds > 0 && !m.streaming {
if m.config.RefreshInterval > 0 && !m.streaming {
// Restart streaming for refresh
cmd := m.startStreaming()
return m, tea.Batch(cmd, m.spinnerTickCmd())
@@ -252,7 +252,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m model) tickCmd() tea.Cmd {
return tea.Tick(time.Duration(m.config.RefreshSeconds)*time.Second, func(t time.Time) tea.Msg {
return tea.Tick(m.config.RefreshInterval, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}

View File

@@ -2,6 +2,7 @@ package ui
import (
"testing"
"time"
"github.com/chenasraf/watchr/internal/runner"
)
@@ -16,7 +17,7 @@ func TestConfig(t *testing.T) {
ShowLineNums: true,
LineNumWidth: 6,
Prompt: "watchr> ",
RefreshSeconds: 5,
RefreshInterval: 5 * time.Second,
}
if cfg.Command != "echo test" {
@@ -51,8 +52,8 @@ func TestConfig(t *testing.T) {
t.Errorf("expected prompt 'watchr> ', got %q", cfg.Prompt)
}
if cfg.RefreshSeconds != 5 {
t.Errorf("expected refresh seconds 5, got %d", cfg.RefreshSeconds)
if cfg.RefreshInterval != 5*time.Second {
t.Errorf("expected refresh interval 5s, got %v", cfg.RefreshInterval)
}
}

View File

@@ -34,7 +34,7 @@ func main() {
flag.IntP("line-width", "w", 6, "Line number width")
flag.StringP("prompt", "p", "watchr> ", "Prompt string")
flag.StringP("shell", "s", "sh", "Shell to use for executing commands")
flag.IntP("refresh", "r", 0, "Auto-refresh interval in seconds (0 = disabled)")
flag.StringP("refresh", "r", "0", "Auto-refresh interval (e.g., 1, 1.5, 500ms, 2s, 5m, 1h; default unit: seconds, 0 = disabled)")
flag.BoolP("interactive", "i", false, "Run shell in interactive mode (sources ~/.bashrc, ~/.zshrc, etc.)")
printUsage := func(w *os.File) {
@@ -108,7 +108,7 @@ func main() {
shell := config.GetString(config.KeyShell)
lineNumWidth := config.GetInt(config.KeyLineWidth)
prompt := config.GetString(config.KeyPrompt)
refreshSeconds := config.GetInt(config.KeyRefresh)
refreshInterval := config.GetDuration(config.KeyRefresh)
showLineNums := config.ShowLineNumbers()
interactive := config.GetBool(config.KeyInteractive)
@@ -130,7 +130,7 @@ func main() {
ShowLineNums: showLineNums,
LineNumWidth: lineNumWidth,
Prompt: prompt,
RefreshSeconds: refreshSeconds,
RefreshInterval: refreshInterval,
Interactive: interactive,
}