10 Commits

Author SHA1 Message Date
github-actions[bot]
ca06d0d7c1 chore(master): release 1.5.0 2025-12-14 18:06:39 +02:00
f15b9e2559 feat: add more refresh duration options & formats 2025-12-14 18:04:40 +02:00
7be6a03d6d docs: update README.md 2025-12-10 15:43:06 +02:00
github-actions[bot]
574ef6abd3 chore(master): release 1.4.0 2025-12-09 01:26:08 +02:00
66c6599506 feat: properly support streaming commands 2025-12-09 01:24:21 +02:00
9ecf9f74b7 feat: add -i/--interactive mode 2025-12-09 00:48:01 +02:00
4340aa1cc0 refactor: fix lint warnings 2025-12-04 17:26:13 +02:00
d01944bfec build: add lint configuration 2025-12-04 17:26:02 +02:00
9ac39a6472 build: add formatted files on commit 2025-12-04 17:10:26 +02:00
ed2f24c0e8 feat: add spinning loader animation 2025-12-04 17:08:38 +02:00
12 changed files with 763 additions and 138 deletions

12
.golangci.yml Normal file
View File

@@ -0,0 +1,12 @@
version: "2"
linters:
enable:
- staticcheck
- govet
- errcheck
- ineffassign
- unused
- gocritic
- intrange
- modernize

View File

@@ -1,5 +1,21 @@
# Changelog
## [1.5.0](https://github.com/chenasraf/watchr/compare/v1.4.0...v1.5.0) (2025-12-14)
### Features
* add more refresh duration options & formats ([f15b9e2](https://github.com/chenasraf/watchr/commit/f15b9e255988b5b754c377e3ae29f9eb4c8a1925))
## [1.4.0](https://github.com/chenasraf/watchr/compare/v1.3.0...v1.4.0) (2025-12-08)
### Features
* add -i/--interactive mode ([9ecf9f7](https://github.com/chenasraf/watchr/commit/9ecf9f74b7e8271dc8f8d2e6e2fc562e87be9953))
* add spinning loader animation ([ed2f24c](https://github.com/chenasraf/watchr/commit/ed2f24c0e820ebaa37f46cc2407648c3e8b1cbbd))
* properly support streaming commands ([66c6599](https://github.com/chenasraf/watchr/commit/66c65995068181d4b4ae74087eb0c15e1ab0edb4))
## [1.3.0](https://github.com/chenasraf/watchr/compare/v1.2.0...v1.3.0) (2025-12-04)

View File

@@ -1,6 +1,11 @@
BIN := $(notdir $(CURDIR))
all: run
all:
@if [ ! -f ".git/hooks/pre-commit" ]; then \
$(MAKE) precommit-install; \
fi
$(MAKE) build
$(MAKE) run
.PHONY: build
build:
@@ -43,6 +48,7 @@ precommit:
echo "Running pre-commit checks..."; \
echo "go fmt"; \
go fmt ./...; \
git add $$STAGED_FILES; \
echo "go vet"; \
go vet ./...; \
echo "golangci-lint"; \

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,13 +102,14 @@ 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
-w, --line-width int Line number width (default 6)
-P, --preview-size string Preview size: number for lines/cols, or number% for percentage (default "40%")
-o, --preview-position string Preview position: bottom, top, left, right (default "bottom")
-i, --interactive Run shell in interactive mode (sources ~/.bashrc, ~/.zshrc, etc.)
```
---
@@ -123,7 +136,8 @@ 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
```
**TOML** (`watchr.toml`):
@@ -134,7 +148,8 @@ 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
```
**JSON** (`watchr.json`):
@@ -146,10 +161,15 @@ refresh = 0
"line-numbers": true,
"line-width": 4,
"prompt": "> ",
"refresh": 0
"refresh": 0,
"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"
@@ -19,6 +22,7 @@ const (
KeyLineWidth = "line-width"
KeyPrompt = "prompt"
KeyRefresh = "refresh"
KeyInteractive = "interactive"
)
// setDefaults sets the default configuration values.
@@ -29,7 +33,8 @@ 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)
}
// Init initializes Viper with config file paths and defaults.
@@ -74,6 +79,7 @@ func BindFlags(flags *pflag.FlagSet) {
_ = viper.BindPFlag(KeyLineWidth, flags.Lookup("line-width"))
_ = viper.BindPFlag(KeyPrompt, flags.Lookup("prompt"))
_ = viper.BindPFlag(KeyRefresh, flags.Lookup("refresh"))
_ = viper.BindPFlag(KeyInteractive, flags.Lookup("interactive"))
// line-numbers is inverted (no-line-numbers flag)
_ = viper.BindPFlag("no-line-numbers", flags.Lookup("no-line-numbers"))
@@ -126,7 +132,8 @@ 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))
}
// getConfigDir returns the appropriate config directory for the OS.
@@ -145,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

@@ -5,7 +5,9 @@ import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
)
@@ -26,15 +28,82 @@ func (l Line) FormatLine(width int, showLineNum bool) string {
// Runner executes commands and captures output
type Runner struct {
Shell string
Command string
Shell string
Command string
Interactive bool
}
// NewRunner creates a new Runner
func NewRunner(shell, command string) *Runner {
return &Runner{
Shell: shell,
Command: command,
Shell: shell,
Command: command,
Interactive: false,
}
}
// NewInteractiveRunner creates a new Runner that sources shell rc files
func NewInteractiveRunner(shell, command string) *Runner {
return &Runner{
Shell: shell,
Command: command,
Interactive: true,
}
}
// buildCommand returns the shell arguments for executing the command.
// If Interactive is true, it wraps the command to source the appropriate rc file.
func (r *Runner) buildCommand() []string {
if !r.Interactive {
return []string{"-c", r.Command}
}
// For interactive mode, source the appropriate rc file before running the command
rcFile := r.getRCFile()
if rcFile != "" {
// Source the rc file if it exists, then run the command
wrappedCmd := fmt.Sprintf("[ -f %s ] && . %s; %s", rcFile, rcFile, r.Command)
return []string{"-c", wrappedCmd}
}
return []string{"-c", r.Command}
}
// getRCFile returns the path to the shell's rc file based on the shell being used.
func (r *Runner) getRCFile() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
shellBase := filepath.Base(r.Shell)
switch shellBase {
case "bash":
// Prefer .bashrc for interactive settings, fall back to .bash_profile
bashrc := filepath.Join(home, ".bashrc")
if _, err := os.Stat(bashrc); err == nil {
return bashrc
}
return filepath.Join(home, ".bash_profile")
case "zsh":
return filepath.Join(home, ".zshrc")
case "fish":
configDir := os.Getenv("XDG_CONFIG_HOME")
if configDir == "" {
configDir = filepath.Join(home, ".config")
}
return filepath.Join(configDir, "fish", "config.fish")
case "ksh":
return filepath.Join(home, ".kshrc")
case "sh":
// POSIX sh uses ENV variable or .profile
if env := os.Getenv("ENV"); env != "" {
return env
}
return filepath.Join(home, ".profile")
default:
// Try common patterns for unknown shells
return filepath.Join(home, "."+shellBase+"rc")
}
}
@@ -46,7 +115,9 @@ type Result struct {
// Run executes the command and returns output lines with exit code
func (r *Runner) Run(ctx context.Context) (Result, error) {
cmd := exec.CommandContext(ctx, r.Shell, "-c", r.Command)
args := r.buildCommand()
cmd := exec.CommandContext(ctx, r.Shell, args...)
cmd.Env = append(os.Environ(), "WATCHR=1")
stdout, err := cmd.StdoutPipe()
if err != nil {
@@ -96,59 +167,140 @@ func (r *Runner) Run(ctx context.Context) (Result, error) {
return Result{Lines: lines, ExitCode: exitCode}, nil
}
// RunStreaming executes the command and streams output lines to the callback
// The callback is called for each line as it arrives
func (r *Runner) RunStreaming(ctx context.Context, lines *[]Line, mu *sync.RWMutex) error {
cmd := exec.CommandContext(ctx, r.Shell, "-c", r.Command)
// StreamingResult holds the state of a streaming command
type StreamingResult struct {
Lines *[]Line
ExitCode int
Done bool
Error error
mu sync.RWMutex
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("failed to create stdout pipe: %w", err)
// GetLines returns a copy of the current lines (thread-safe)
func (s *StreamingResult) GetLines() []Line {
s.mu.RLock()
defer s.mu.RUnlock()
if s.Lines == nil {
return nil
}
result := make([]Line, len(*s.Lines))
copy(result, *s.Lines)
return result
}
// LineCount returns the current number of lines (thread-safe)
func (s *StreamingResult) LineCount() int {
s.mu.RLock()
defer s.mu.RUnlock()
if s.Lines == nil {
return 0
}
return len(*s.Lines)
}
// IsDone returns whether the command has finished (thread-safe)
func (s *StreamingResult) IsDone() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.Done
}
// RunStreaming executes the command and streams output lines in the background.
// Returns a StreamingResult that can be polled for updates.
// The command runs until ctx is cancelled or it completes naturally.
func (r *Runner) RunStreaming(ctx context.Context) *StreamingResult {
result := &StreamingResult{
Lines: &[]Line{},
ExitCode: -1,
Done: false,
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("failed to create stderr pipe: %w", err)
}
go func() {
args := r.buildCommand()
cmd := exec.CommandContext(ctx, r.Shell, args...)
cmd.Env = append(os.Environ(), "WATCHR=1")
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start command: %w", err)
}
lineNum := 1
// Read from both stdout and stderr concurrently
var wg sync.WaitGroup
wg.Add(2)
readPipe := func(pipe io.Reader) {
defer wg.Done()
scanner := bufio.NewScanner(pipe)
for scanner.Scan() {
mu.Lock()
*lines = append(*lines, Line{
Number: lineNum,
Content: scanner.Text(),
})
lineNum++
mu.Unlock()
stdout, err := cmd.StdoutPipe()
if err != nil {
result.mu.Lock()
result.Error = fmt.Errorf("failed to create stdout pipe: %w", err)
result.Done = true
result.mu.Unlock()
return
}
}
go readPipe(stdout)
go readPipe(stderr)
stderr, err := cmd.StderrPipe()
if err != nil {
result.mu.Lock()
result.Error = fmt.Errorf("failed to create stderr pipe: %w", err)
result.Done = true
result.mu.Unlock()
return
}
wg.Wait()
if err := cmd.Start(); err != nil {
result.mu.Lock()
result.Error = fmt.Errorf("failed to start command: %w", err)
result.Done = true
result.mu.Unlock()
return
}
// Wait for command to finish (ignore exit code - we still want to show output)
_ = cmd.Wait()
lineNum := 1
var lineNumMu sync.Mutex
return nil
// Read from both stdout and stderr concurrently
var wg sync.WaitGroup
wg.Add(2)
readPipe := func(pipe io.Reader) {
defer wg.Done()
scanner := bufio.NewScanner(pipe)
for scanner.Scan() {
lineNumMu.Lock()
currentLineNum := lineNum
lineNum++
lineNumMu.Unlock()
result.mu.Lock()
*result.Lines = append(*result.Lines, Line{
Number: currentLineNum,
Content: scanner.Text(),
})
result.mu.Unlock()
}
}
go readPipe(stdout)
go readPipe(stderr)
wg.Wait()
// Wait for command to finish and get exit code
exitCode := 0
if err := cmd.Wait(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else if ctx.Err() != nil {
// Context was cancelled
exitCode = -1
}
}
result.mu.Lock()
result.ExitCode = exitCode
result.Done = true
result.mu.Unlock()
}()
return result
}
// RunSimple executes the command and returns output as string slice
func (r *Runner) RunSimple(ctx context.Context) ([]string, error) {
cmd := exec.CommandContext(ctx, r.Shell, "-c", r.Command)
args := r.buildCommand()
cmd := exec.CommandContext(ctx, r.Shell, args...)
cmd.Env = append(os.Environ(), "WATCHR=1")
output, err := cmd.CombinedOutput()
if err != nil {
// Still return output even on error (non-zero exit)

View File

@@ -14,6 +14,97 @@ func TestNewRunner(t *testing.T) {
if r.Command != "echo hello" {
t.Errorf("expected command 'echo hello', got %q", r.Command)
}
if r.Interactive {
t.Errorf("expected Interactive to be false for NewRunner")
}
}
func TestNewInteractiveRunner(t *testing.T) {
r := NewInteractiveRunner("bash", "my_func")
if r.Shell != "bash" {
t.Errorf("expected shell 'bash', got %q", r.Shell)
}
if r.Command != "my_func" {
t.Errorf("expected command 'my_func', got %q", r.Command)
}
if !r.Interactive {
t.Errorf("expected Interactive to be true for NewInteractiveRunner")
}
}
func TestRunner_buildCommand(t *testing.T) {
tests := []struct {
name string
shell string
command string
interactive bool
wantFirst string
}{
{
name: "non-interactive",
shell: "sh",
command: "echo hello",
interactive: false,
wantFirst: "-c",
},
{
name: "interactive bash",
shell: "bash",
command: "my_func",
interactive: true,
wantFirst: "-c",
},
{
name: "interactive zsh",
shell: "/bin/zsh",
command: "my_alias",
interactive: true,
wantFirst: "-c",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var r *Runner
if tt.interactive {
r = NewInteractiveRunner(tt.shell, tt.command)
} else {
r = NewRunner(tt.shell, tt.command)
}
args := r.buildCommand()
if len(args) != 2 {
t.Fatalf("expected 2 args, got %d", len(args))
}
if args[0] != tt.wantFirst {
t.Errorf("expected first arg %q, got %q", tt.wantFirst, args[0])
}
// For interactive mode, the command should contain sourcing logic
if tt.interactive {
if !contains(args[1], tt.command) {
t.Errorf("expected command %q to be in args[1] %q", tt.command, args[1])
}
} else {
if args[1] != tt.command {
t.Errorf("expected args[1] to be %q, got %q", tt.command, args[1])
}
}
})
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
}
func containsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func TestRunner_Run(t *testing.T) {

View File

@@ -33,29 +33,34 @@ type Config struct {
ShowLineNums bool
LineNumWidth int
Prompt string
RefreshSeconds int
RefreshInterval time.Duration
Interactive bool
}
// model represents the application state
type model struct {
config Config
lines []runner.Line
filtered []int // indices into lines that match filter
cursor int // cursor position in filtered list
offset int // scroll offset for visible window
filter string
filterMode bool
showPreview bool
showHelp bool // help overlay visible
width int
height int
runner *runner.Runner
ctx context.Context
cancel context.CancelFunc
loading bool
errorMsg string
statusMsg string // temporary status message (e.g., "Yanked!")
exitCode int // last command exit code
config Config
lines []runner.Line
filtered []int // indices into lines that match filter
cursor int // cursor position in filtered list
offset int // scroll offset for visible window
filter string
filterMode bool
showPreview bool
showHelp bool // help overlay visible
width int
height int
runner *runner.Runner
ctx context.Context
cancel context.CancelFunc
loading bool
streaming bool // true while command is running (streaming output)
streamResult *runner.StreamingResult // current streaming result
lastLineCount int // track line count for updates
spinnerFrame int // current spinner animation frame
errorMsg string
statusMsg string // temporary status message (e.g., "Yanked!")
exitCode int // last command exit code
}
// messages
@@ -66,6 +71,12 @@ type resultMsg struct {
type errMsg struct{ err error }
type tickMsg time.Time
type clearStatusMsg struct{}
type spinnerTickMsg time.Time
type streamTickMsg time.Time // periodic check for streaming updates
type startStreamMsg struct{} // trigger to start streaming
// Spinner frames for the loading animation
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
func (e errMsg) Error() string { return e.err.Error() }
@@ -95,6 +106,14 @@ func copyToClipboard(text string) error {
func initialModel(cfg Config) model {
ctx, cancel := context.WithCancel(context.Background())
var r *runner.Runner
if cfg.Interactive {
r = runner.NewInteractiveRunner(cfg.Shell, cfg.Command)
} else {
r = runner.NewRunner(cfg.Shell, cfg.Command)
}
return model{
config: cfg,
lines: []runner.Line{},
@@ -104,30 +123,50 @@ func initialModel(cfg Config) model {
filter: "",
filterMode: false,
showPreview: false,
runner: runner.NewRunner(cfg.Shell, cfg.Command),
runner: r,
ctx: ctx,
cancel: cancel,
loading: true,
}
}
func (m model) Init() tea.Cmd {
return m.runCommand()
}
func (m model) runCommand() tea.Cmd {
r := m.runner
ctx := m.ctx
func (m *model) Init() tea.Cmd {
// Send a message to start streaming (handled in Update with pointer receiver)
return func() tea.Msg {
result, err := r.Run(ctx)
if err != nil {
return errMsg{err}
}
return resultMsg{lines: result.Lines, exitCode: result.ExitCode}
return startStreamMsg{}
}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m model) spinnerTickCmd() tea.Cmd {
return tea.Tick(80*time.Millisecond, func(t time.Time) tea.Msg {
return spinnerTickMsg(t)
})
}
func (m model) streamTickCmd() tea.Cmd {
return tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg {
return streamTickMsg(t)
})
}
func (m *model) startStreaming() tea.Cmd {
// Cancel any existing context and create a new one
if m.cancel != nil {
m.cancel()
}
m.ctx, m.cancel = context.WithCancel(context.Background())
m.streamResult = m.runner.RunStreaming(m.ctx)
m.streaming = true
m.loading = true
m.lastLineCount = 0
m.exitCode = -1
m.errorMsg = ""
return m.streamTickCmd()
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
return m.handleKeyPress(msg)
@@ -137,37 +176,83 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height
return m, nil
case startStreamMsg:
cmd := m.startStreaming()
return m, tea.Batch(cmd, m.spinnerTickCmd())
case resultMsg:
m.lines = msg.lines
m.exitCode = msg.exitCode
m.loading = false
m.streaming = false
m.updateFiltered()
return m, nil
case streamTickMsg:
if m.streamResult == nil {
return m, nil
}
// Check for new lines
newLines := m.streamResult.GetLines()
newCount := len(newLines)
if newCount != m.lastLineCount {
m.lines = newLines
m.lastLineCount = newCount
m.updateFiltered()
}
// Check if command completed
if m.streamResult.IsDone() {
m.streaming = false
m.loading = false
m.exitCode = m.streamResult.ExitCode
if m.streamResult.Error != nil {
m.errorMsg = m.streamResult.Error.Error()
}
// If auto-refresh is enabled, schedule the next run
if m.config.RefreshInterval > 0 {
return m, m.tickCmd()
}
return m, nil
}
// Continue streaming
return m, m.streamTickCmd()
case tickMsg:
if m.config.RefreshSeconds > 0 {
return m, tea.Batch(
m.runCommand(),
m.tickCmd(),
)
if m.config.RefreshInterval > 0 && !m.streaming {
// Restart streaming for refresh
cmd := m.startStreaming()
return m, tea.Batch(cmd, m.spinnerTickCmd())
}
return m, nil
case errMsg:
m.errorMsg = msg.Error()
m.loading = false
m.streaming = false
return m, nil
case clearStatusMsg:
m.statusMsg = ""
return m, nil
case spinnerTickMsg:
if m.loading || m.streaming {
m.spinnerFrame = (m.spinnerFrame + 1) % len(spinnerFrames)
return m, m.spinnerTickCmd()
}
return m, nil
}
return m, nil
}
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)
})
}
@@ -238,8 +323,9 @@ func (m *model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.showPreview = !m.showPreview
m.adjustOffset() // Keep selected line visible after preview toggle
case "r", "ctrl+r":
m.loading = true
return m, m.runCommand()
// Restart streaming
cmd := m.startStreaming()
return m, tea.Batch(cmd, m.spinnerTickCmd())
case "/":
m.filterMode = true
m.filter = ""
@@ -290,16 +376,9 @@ func (m *model) adjustOffset() {
idealOffset := m.cursor - visible/2
// Clamp to valid range
if idealOffset < 0 {
idealOffset = 0
}
maxOffset := len(m.filtered) - visible
if maxOffset < 0 {
maxOffset = 0
}
if idealOffset > maxOffset {
idealOffset = maxOffset
}
idealOffset = max(idealOffset, 0)
maxOffset := max(len(m.filtered)-visible, 0)
idealOffset = min(idealOffset, maxOffset)
m.offset = idealOffset
}
@@ -343,17 +422,17 @@ func truncateToWidth(s string, maxWidth int) string {
}
// Truncate rune by rune until we fit
result := ""
var result strings.Builder
currentWidth := 0
for _, r := range s {
runeWidth := lipgloss.Width(string(r))
if currentWidth+runeWidth > targetWidth {
break
}
result += string(r)
result.WriteRune(r)
currentWidth += runeWidth
}
return result + ellipsis
return result.String() + ellipsis
}
// wrapText wraps text to fit within the given width, returning multiple lines.
@@ -646,9 +725,9 @@ func overlayBox(base string, box string, boxWidth, boxHeight, screenWidth, scree
return strings.Join(baseLines, "\n")
}
func (m model) View() string {
func (m *model) View() string {
if m.width == 0 || m.height == 0 {
return "Loading..."
return spinnerFrames[m.spinnerFrame] + " Running command…"
}
// Render the main UI
@@ -740,14 +819,19 @@ func (m model) renderMainView() string {
prefix := titleStyle.Render("watchr") + " • "
var commandLine string
if m.loading {
switch {
case m.streaming:
// Streaming - show streaming indicator
streamStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // cyan
commandLine = prefix + streamStyle.Render("◉ "+m.config.Command)
case m.loading:
// Still loading - no status yet
commandLine = prefix + m.config.Command
} else if m.exitCode == 0 {
case m.exitCode == 0:
// Success - green checkmark and green command
successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // green
commandLine = prefix + successStyle.Render("✓ "+m.config.Command)
} else {
default:
// Failure - red cross with exit code and red command
failStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) // red
commandLine = prefix + failStyle.Render(fmt.Sprintf("✗ [%d] %s", m.exitCode, m.config.Command))
@@ -755,15 +839,18 @@ func (m model) renderMainView() string {
// Build prompt line (will go at bottom)
var promptLine string
if m.filterMode {
switch {
case m.filterMode:
promptLine = filterStyle.Render(fmt.Sprintf("/%s█", m.filter))
} else if m.filter != "" {
case m.filter != "":
promptLine = promptStyle.Render(fmt.Sprintf("%s (filter: %s)", m.config.Prompt, m.filter))
} else {
default:
promptLine = promptStyle.Render(m.config.Prompt)
}
if m.loading {
promptLine += " [loading...]"
if m.streaming {
promptLine += " " + spinnerFrames[m.spinnerFrame] + " Streaming…"
} else if m.loading {
promptLine += " " + spinnerFrames[m.spinnerFrame] + " Running command…"
}
if m.statusMsg != "" {
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // green
@@ -792,7 +879,7 @@ func (m model) renderMainView() string {
// Build lines view
var listLines []string
for i := 0; i < listHeight; i++ {
for i := range listHeight {
lineIdx := m.offset + i
if lineIdx >= len(m.filtered) {
// Empty line to fill space
@@ -851,7 +938,7 @@ func (m model) renderMainView() string {
// Pad to full width for selection highlight
padding := fullWidth - lipgloss.Width(lineText)
if padding > 0 {
lineText = lineText + strings.Repeat(" ", padding)
lineText += strings.Repeat(" ", padding)
}
lineText = selectedStyle.Render(lineText)
}
@@ -902,7 +989,7 @@ func (m model) renderMainView() string {
// Content area (with optional preview)
if !m.showPreview {
// Just content lines, padded to fill height
for i := 0; i < listHeight; i++ {
for i := range listHeight {
if i < len(listLines) {
lines = append(lines, padLine(listLines[i]))
} else {
@@ -933,7 +1020,7 @@ func (m model) renderMainView() string {
// Separator (no vertical split for top/bottom preview)
lines = append(lines, hLine(leftT, rightT, 0))
// Then content, padded to fill height
for i := 0; i < listHeight; i++ {
for i := range listHeight {
if i < len(listLines) {
lines = append(lines, padLine(listLines[i]))
} else {
@@ -942,7 +1029,7 @@ func (m model) renderMainView() string {
}
} else {
// Content first, padded to fill height
for i := 0; i < listHeight; i++ {
for i := range listHeight {
if i < len(listLines) {
lines = append(lines, padLine(listLines[i]))
} else {
@@ -998,7 +1085,7 @@ func (m model) renderMainView() string {
}
// Build combined lines
for i := 0; i < listHeight; i++ {
for i := range listHeight {
var leftContent, rightContent string
var leftIsPreview, rightIsPreview bool
@@ -1041,7 +1128,7 @@ func Run(cfg Config) error {
}
m := initialModel(cfg)
p := tea.NewProgram(m, tea.WithAltScreen())
p := tea.NewProgram(&m, tea.WithAltScreen())
_, err := p.Run()
return err

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,8 @@ 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) {
_, _ = fmt.Fprintf(w, "Usage: watchr [options] <command to run>\n\n")
@@ -107,8 +108,9 @@ 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)
// Parse preview size (e.g., "40" for lines/cols, "40%" for percentage)
previewSizeIsPercent := strings.HasSuffix(previewSize, "%")
@@ -128,7 +130,8 @@ func main() {
ShowLineNums: showLineNums,
LineNumWidth: lineNumWidth,
Prompt: prompt,
RefreshSeconds: refreshSeconds,
RefreshInterval: refreshInterval,
Interactive: interactive,
}
if err := ui.Run(uiConfig); err != nil {

View File

@@ -1 +1 @@
1.3.0
1.5.0