mirror of
https://github.com/chenasraf/watchr.git
synced 2026-05-18 01:29:05 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d130becd99 | ||
| 5703a61ddb | |||
|
|
ca06d0d7c1 | ||
| f15b9e2559 | |||
| 7be6a03d6d | |||
|
|
574ef6abd3 | ||
| 66c6599506 | |||
| 9ecf9f74b7 | |||
| 4340aa1cc0 | |||
| d01944bfec | |||
| 9ac39a6472 | |||
| ed2f24c0e8 | |||
|
|
a3c46bfa7c | ||
| 5e1b807105 | |||
| 51b9e92ddd | |||
| 320874a61a | |||
| cdd895d19a |
4
.editorconfig
Normal file
4
.editorconfig
Normal file
@@ -0,0 +1,4 @@
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
12
.golangci.yml
Normal file
12
.golangci.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- staticcheck
|
||||
- govet
|
||||
- errcheck
|
||||
- ineffassign
|
||||
- unused
|
||||
- gocritic
|
||||
- intrange
|
||||
- modernize
|
||||
36
CHANGELOG.md
36
CHANGELOG.md
@@ -1,5 +1,41 @@
|
||||
# Changelog
|
||||
|
||||
## [1.5.1](https://github.com/chenasraf/watchr/compare/v1.5.0...v1.5.1) (2025-12-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* esc button behavior ([5703a61](https://github.com/chenasraf/watchr/commit/5703a61ddb3ee25e58470c537d53bf4a463bc632))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add exit status to command header ([51b9e92](https://github.com/chenasraf/watchr/commit/51b9e92ddd9766d109429878cc706761e1f46e47))
|
||||
* help overlay window ([cdd895d](https://github.com/chenasraf/watchr/commit/cdd895d19ac78b3bc34ccde7da9f2136b6661abd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* current line style ([5e1b807](https://github.com/chenasraf/watchr/commit/5e1b8071054c9727a5a8c3db85c88e1ede8e27d0))
|
||||
|
||||
## [1.2.0](https://github.com/chenasraf/watchr/compare/v1.1.0...v1.2.0) (2025-12-03)
|
||||
|
||||
|
||||
|
||||
11
Makefile
11
Makefile
@@ -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:
|
||||
@@ -22,6 +27,10 @@ install: build
|
||||
uninstall:
|
||||
rm -f ~/.local/bin/$(BIN)
|
||||
|
||||
.PHONY: lint
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
.PHONY: precommit-install
|
||||
precommit-install:
|
||||
@echo "Installing pre-commit hooks..."
|
||||
|
||||
29
README.md
29
README.md
@@ -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):
|
||||
@@ -177,6 +197,7 @@ Configuration values are applied in this order (later sources override earlier o
|
||||
| `/` | Enter filter mode |
|
||||
| `Esc` | Exit filter mode / clear filter |
|
||||
| `y` | Yank (copy) selected line |
|
||||
| `?` | Show help overlay |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@@ -26,34 +28,109 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes the command and returns output lines
|
||||
func (r *Runner) Run(ctx context.Context) ([]Line, error) {
|
||||
cmd := exec.CommandContext(ctx, r.Shell, "-c", r.Command)
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
// Result contains the output and exit code of a command run
|
||||
type Result struct {
|
||||
Lines []Line
|
||||
ExitCode int
|
||||
}
|
||||
|
||||
// Run executes the command and returns output lines with exit code
|
||||
func (r *Runner) Run(ctx context.Context) (Result, error) {
|
||||
args := r.buildCommand()
|
||||
cmd := exec.CommandContext(ctx, r.Shell, args...)
|
||||
cmd.Env = append(os.Environ(), "WATCHR=1")
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||
return Result{}, fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create stderr pipe: %w", err)
|
||||
return Result{}, fmt.Errorf("failed to create stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start command: %w", err)
|
||||
return Result{}, fmt.Errorf("failed to start command: %w", err)
|
||||
}
|
||||
|
||||
var lines []Line
|
||||
@@ -79,65 +156,151 @@ func (r *Runner) Run(ctx context.Context) ([]Line, error) {
|
||||
lineNum++
|
||||
}
|
||||
|
||||
// Wait for command to finish (ignore exit code - we still want to show output)
|
||||
_ = cmd.Wait()
|
||||
|
||||
return lines, 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)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
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()
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
go readPipe(stdout)
|
||||
go readPipe(stderr)
|
||||
return Result{Lines: lines, ExitCode: exitCode}, nil
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// StreamingResult holds the state of a streaming command
|
||||
type StreamingResult struct {
|
||||
Lines *[]Line
|
||||
ExitCode int
|
||||
Done bool
|
||||
Error error
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// Wait for command to finish (ignore exit code - we still want to show output)
|
||||
_ = cmd.Wait()
|
||||
// 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
|
||||
}
|
||||
|
||||
return nil
|
||||
// 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,
|
||||
}
|
||||
|
||||
go func() {
|
||||
args := r.buildCommand()
|
||||
cmd := exec.CommandContext(ctx, r.Shell, args...)
|
||||
cmd.Env = append(os.Environ(), "WATCHR=1")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
lineNum := 1
|
||||
var lineNumMu sync.Mutex
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -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) {
|
||||
@@ -52,17 +143,17 @@ func TestRunner_Run(t *testing.T) {
|
||||
r := NewRunner(tt.shell, tt.command)
|
||||
ctx := context.Background()
|
||||
|
||||
lines, err := r.Run(ctx)
|
||||
result, err := r.Run(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(lines) != tt.wantLines {
|
||||
t.Errorf("expected %d lines, got %d", tt.wantLines, len(lines))
|
||||
if len(result.Lines) != tt.wantLines {
|
||||
t.Errorf("expected %d lines, got %d", tt.wantLines, len(result.Lines))
|
||||
}
|
||||
|
||||
if tt.wantLines > 0 && lines[0].Content != tt.wantContent {
|
||||
t.Errorf("expected first line %q, got %q", tt.wantContent, lines[0].Content)
|
||||
if tt.wantLines > 0 && result.Lines[0].Content != tt.wantContent {
|
||||
t.Errorf("expected first line %q, got %q", tt.wantContent, result.Lines[0].Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -103,13 +194,17 @@ func TestRunner_RunWithFailingCommand(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Should not return error for non-zero exit, just empty output
|
||||
lines, err := r.Run(ctx)
|
||||
result, err := r.Run(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(lines) != 0 {
|
||||
t.Errorf("expected 0 lines for exit 1, got %d", len(lines))
|
||||
if len(result.Lines) != 0 {
|
||||
t.Errorf("expected 0 lines for exit 1, got %d", len(result.Lines))
|
||||
}
|
||||
|
||||
if result.ExitCode != 1 {
|
||||
t.Errorf("expected exit code 1, got %d", result.ExitCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,17 +212,21 @@ func TestRunner_RunWithOutputAndError(t *testing.T) {
|
||||
r := NewRunner("sh", "echo 'output'; exit 1")
|
||||
ctx := context.Background()
|
||||
|
||||
lines, err := r.Run(ctx)
|
||||
result, err := r.Run(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("expected 1 line, got %d", len(lines))
|
||||
if len(result.Lines) != 1 {
|
||||
t.Fatalf("expected 1 line, got %d", len(result.Lines))
|
||||
}
|
||||
|
||||
if lines[0].Content != "output" {
|
||||
t.Errorf("expected 'output', got %q", lines[0].Content)
|
||||
if result.Lines[0].Content != "output" {
|
||||
t.Errorf("expected 'output', got %q", result.Lines[0].Content)
|
||||
}
|
||||
|
||||
if result.ExitCode != 1 {
|
||||
t.Errorf("expected exit code 1, got %d", result.ExitCode)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,34 +33,50 @@ 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
|
||||
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!")
|
||||
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
|
||||
type linesMsg []runner.Line
|
||||
type resultMsg struct {
|
||||
lines []runner.Line
|
||||
exitCode int
|
||||
}
|
||||
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() }
|
||||
|
||||
@@ -90,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{},
|
||||
@@ -99,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 {
|
||||
lines, err := r.Run(ctx)
|
||||
if err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
return linesMsg(lines)
|
||||
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)
|
||||
@@ -132,41 +176,97 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
|
||||
case linesMsg:
|
||||
m.lines = []runner.Line(msg)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// In help mode, any key closes it
|
||||
if m.showHelp {
|
||||
switch msg.String() {
|
||||
case "?", "esc", "q", "enter":
|
||||
m.showHelp = false
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// In filter mode, handle text input
|
||||
if m.filterMode {
|
||||
switch msg.Type {
|
||||
@@ -195,7 +295,16 @@ func (m *model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// Normal mode keybindings
|
||||
switch msg.String() {
|
||||
case "q", "esc", "ctrl+c":
|
||||
case "q", "ctrl+c":
|
||||
m.cancel()
|
||||
return m, tea.Quit
|
||||
case "esc":
|
||||
// Clear filter if active, otherwise quit
|
||||
if m.filter != "" {
|
||||
m.filter = ""
|
||||
m.updateFiltered()
|
||||
return m, nil
|
||||
}
|
||||
m.cancel()
|
||||
return m, tea.Quit
|
||||
|
||||
@@ -223,11 +332,14 @@ 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 = ""
|
||||
case "?":
|
||||
m.showHelp = true
|
||||
case "y":
|
||||
// Yank (copy) selected line to clipboard
|
||||
if len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) {
|
||||
@@ -273,16 +385,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
|
||||
}
|
||||
@@ -298,8 +403,8 @@ func (m model) previewSize() int {
|
||||
}
|
||||
|
||||
func (m model) visibleLines() int {
|
||||
// Fixed lines: top border (1) + header (2) + separator (1) + bottom border (1) + prompt (1) = 6
|
||||
fixedLines := 6
|
||||
// Fixed lines: top border (1) + header (1) + separator (1) + bottom border (1) + prompt (1) = 5
|
||||
fixedLines := 5
|
||||
if m.showPreview && (m.config.PreviewPosition == PreviewTop || m.config.PreviewPosition == PreviewBottom) {
|
||||
// Add preview height + separator between content and preview
|
||||
return m.height - fixedLines - m.previewSize() - 1
|
||||
@@ -326,17 +431,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.
|
||||
@@ -392,11 +497,261 @@ func (m *model) updateFiltered() {
|
||||
m.offset = 0
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
if m.width == 0 || m.height == 0 {
|
||||
return "Loading..."
|
||||
// renderHelpOverlay creates the help box content (without positioning)
|
||||
func (m model) renderHelpOverlay() (box string, boxWidth, boxHeight int) {
|
||||
keyStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("10")) // green
|
||||
|
||||
descStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("252")) // light gray
|
||||
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("12")) // blue
|
||||
|
||||
// Define keybindings
|
||||
bindings := []struct {
|
||||
key string
|
||||
desc string
|
||||
}{
|
||||
{"j / k", "Move down / up"},
|
||||
{"g / G", "Go to first / last line"},
|
||||
{"Ctrl+d / Ctrl+u", "Half page down / up"},
|
||||
{"PgDn / PgUp", "Full page down / up"},
|
||||
{"Ctrl+f / Ctrl+b", "Full page down / up"},
|
||||
{"", ""},
|
||||
{"p", "Toggle preview pane"},
|
||||
{"/", "Enter filter mode"},
|
||||
{"Esc", "Exit filter / clear"},
|
||||
{"", ""},
|
||||
{"r / Ctrl+r", "Reload command"},
|
||||
{"y", "Copy line to clipboard"},
|
||||
{"q / Esc", "Quit"},
|
||||
{"?", "Toggle this help"},
|
||||
}
|
||||
|
||||
// Build content
|
||||
var content strings.Builder
|
||||
content.WriteString(titleStyle.Render("Keybindings"))
|
||||
content.WriteString("\n\n")
|
||||
|
||||
for _, b := range bindings {
|
||||
if b.key == "" {
|
||||
content.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
key := keyStyle.Render(fmt.Sprintf("%-18s", b.key))
|
||||
desc := descStyle.Render(b.desc)
|
||||
content.WriteString(fmt.Sprintf(" %s %s\n", key, desc))
|
||||
}
|
||||
|
||||
content.WriteString("\n")
|
||||
content.WriteString(descStyle.Render("Press any key to close"))
|
||||
|
||||
// Create box style
|
||||
boxStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("12")).
|
||||
Padding(1, 2)
|
||||
|
||||
box = boxStyle.Render(content.String())
|
||||
boxWidth = lipgloss.Width(box)
|
||||
boxHeight = lipgloss.Height(box)
|
||||
|
||||
return box, boxWidth, boxHeight
|
||||
}
|
||||
|
||||
// splitAtVisualWidth splits a string at a visual width position, handling ANSI codes
|
||||
// Returns (left part, right part) where left has exactly targetWidth visual width
|
||||
func splitAtVisualWidth(s string, targetWidth int) (string, string) {
|
||||
var left, right strings.Builder
|
||||
visualWidth := 0
|
||||
inEscape := false
|
||||
runes := []rune(s)
|
||||
|
||||
i := 0
|
||||
// Build left part up to targetWidth
|
||||
for i < len(runes) && visualWidth < targetWidth {
|
||||
r := runes[i]
|
||||
|
||||
if r == '\x1b' {
|
||||
// Start of ANSI escape sequence - include it in left part
|
||||
left.WriteRune(r)
|
||||
i++
|
||||
for i < len(runes) && !isAnsiTerminator(runes[i]) {
|
||||
left.WriteRune(runes[i])
|
||||
i++
|
||||
}
|
||||
if i < len(runes) {
|
||||
left.WriteRune(runes[i]) // terminator
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
runeWidth := lipgloss.Width(string(r))
|
||||
if visualWidth+runeWidth <= targetWidth {
|
||||
left.WriteRune(r)
|
||||
visualWidth += runeWidth
|
||||
i++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Pad left if needed
|
||||
for visualWidth < targetWidth {
|
||||
left.WriteRune(' ')
|
||||
visualWidth++
|
||||
}
|
||||
|
||||
// Skip runes in the "overlay zone" - we don't need them for right part calculation
|
||||
// The caller will handle inserting the overlay content
|
||||
|
||||
// Build right part from remaining
|
||||
for ; i < len(runes); i++ {
|
||||
r := runes[i]
|
||||
if r == '\x1b' {
|
||||
right.WriteRune(r)
|
||||
i++
|
||||
for i < len(runes) && !isAnsiTerminator(runes[i]) {
|
||||
right.WriteRune(runes[i])
|
||||
i++
|
||||
}
|
||||
if i < len(runes) {
|
||||
right.WriteRune(runes[i])
|
||||
}
|
||||
continue
|
||||
}
|
||||
right.WriteRune(r)
|
||||
}
|
||||
|
||||
_ = inEscape // unused but kept for clarity
|
||||
return left.String(), right.String()
|
||||
}
|
||||
|
||||
// skipVisualWidth skips a number of visual width units in a string, handling ANSI codes
|
||||
// It preserves and returns ANSI sequences encountered during skipping so styling can be restored
|
||||
func skipVisualWidth(s string, skipWidth int) string {
|
||||
var result strings.Builder
|
||||
var ansiState strings.Builder // collect ANSI codes while skipping
|
||||
visualWidth := 0
|
||||
runes := []rune(s)
|
||||
|
||||
i := 0
|
||||
// Skip until we've passed skipWidth, but collect ANSI codes
|
||||
for i < len(runes) && visualWidth < skipWidth {
|
||||
r := runes[i]
|
||||
|
||||
if r == '\x1b' {
|
||||
// ANSI escape - collect it (don't count visual width)
|
||||
ansiState.WriteRune(r)
|
||||
i++
|
||||
for i < len(runes) && !isAnsiTerminator(runes[i]) {
|
||||
ansiState.WriteRune(runes[i])
|
||||
i++
|
||||
}
|
||||
if i < len(runes) {
|
||||
ansiState.WriteRune(runes[i]) // terminator
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
runeWidth := lipgloss.Width(string(r))
|
||||
visualWidth += runeWidth
|
||||
i++
|
||||
}
|
||||
|
||||
// Prepend collected ANSI state to restore styling
|
||||
result.WriteString(ansiState.String())
|
||||
|
||||
// Output the rest
|
||||
for ; i < len(runes); i++ {
|
||||
result.WriteRune(runes[i])
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func isAnsiTerminator(r rune) bool {
|
||||
return (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')
|
||||
}
|
||||
|
||||
// overlayBox composites an overlay box on top of a base view
|
||||
func overlayBox(base string, box string, boxWidth, boxHeight, screenWidth, screenHeight int) string {
|
||||
// ANSI reset sequence to stop any styling from bleeding into overlay
|
||||
const ansiReset = "\x1b[0m"
|
||||
|
||||
// Split base into lines
|
||||
baseLines := strings.Split(base, "\n")
|
||||
|
||||
// Ensure we have enough lines
|
||||
for len(baseLines) < screenHeight {
|
||||
baseLines = append(baseLines, "")
|
||||
}
|
||||
|
||||
// Split box into lines
|
||||
boxLines := strings.Split(box, "\n")
|
||||
|
||||
// Calculate center position
|
||||
startX := (screenWidth - boxWidth) / 2
|
||||
startY := (screenHeight - boxHeight) / 2
|
||||
|
||||
if startX < 0 {
|
||||
startX = 0
|
||||
}
|
||||
if startY < 0 {
|
||||
startY = 0
|
||||
}
|
||||
|
||||
// Overlay box onto base
|
||||
for i, boxLine := range boxLines {
|
||||
y := startY + i
|
||||
if y >= len(baseLines) {
|
||||
break
|
||||
}
|
||||
|
||||
baseLine := baseLines[y]
|
||||
baseVisualWidth := lipgloss.Width(baseLine)
|
||||
|
||||
// Get left part (before overlay)
|
||||
leftPart, _ := splitAtVisualWidth(baseLine, startX)
|
||||
|
||||
// Get right part (after overlay)
|
||||
endX := startX + boxWidth
|
||||
var rightPart string
|
||||
if endX < baseVisualWidth {
|
||||
rightPart = skipVisualWidth(baseLine, endX)
|
||||
}
|
||||
|
||||
// Combine: left + reset + box + right
|
||||
// Reset before overlay to stop highlight bleeding into overlay
|
||||
baseLines[y] = leftPart + ansiReset + boxLine + rightPart
|
||||
}
|
||||
|
||||
return strings.Join(baseLines, "\n")
|
||||
}
|
||||
|
||||
func (m *model) View() string {
|
||||
if m.width == 0 || m.height == 0 {
|
||||
return spinnerFrames[m.spinnerFrame] + " Running command…"
|
||||
}
|
||||
|
||||
// Render the main UI
|
||||
mainView := m.renderMainView()
|
||||
|
||||
// Overlay help if active
|
||||
if m.showHelp {
|
||||
box, boxWidth, boxHeight := m.renderHelpOverlay()
|
||||
return overlayBox(mainView, box, boxWidth, boxHeight, m.width, m.height)
|
||||
}
|
||||
|
||||
return mainView
|
||||
}
|
||||
|
||||
func (m model) renderMainView() string {
|
||||
// Box drawing characters (rounded)
|
||||
const (
|
||||
topLeft = "╭"
|
||||
@@ -415,16 +770,12 @@ func (m model) View() string {
|
||||
borderStyle := lipgloss.NewStyle().Foreground(borderColor)
|
||||
|
||||
// Styles
|
||||
headerTextStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("12"))
|
||||
|
||||
promptStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("14"))
|
||||
|
||||
selectedStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("236")).
|
||||
Foreground(lipgloss.Color("15")).
|
||||
Background(lipgloss.Color("15")).
|
||||
Foreground(lipgloss.Color("#000000")).
|
||||
Bold(true)
|
||||
|
||||
lineNumStyle := lipgloss.NewStyle().
|
||||
@@ -472,27 +823,58 @@ func (m model) View() string {
|
||||
return borderStyle.Render(vertical) + content + borderStyle.Render(vertical)
|
||||
}
|
||||
|
||||
// Build header content
|
||||
header := headerTextStyle.Render("r reload • q quit • j/k move • g/G first/last • ^d/u/f/b scroll • p preview • / filter • y yank")
|
||||
commandLine := fmt.Sprintf("Command: %s", m.config.Command)
|
||||
// Build header content with status indicator
|
||||
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true) // blue
|
||||
prefix := titleStyle.Render("watchr") + " • "
|
||||
|
||||
var commandLine string
|
||||
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
|
||||
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)
|
||||
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))
|
||||
}
|
||||
|
||||
// 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
|
||||
promptLine += " " + statusStyle.Render(m.statusMsg)
|
||||
}
|
||||
|
||||
// Add help hint on the right
|
||||
helpHint := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render("? for help")
|
||||
promptWidth := lipgloss.Width(promptLine)
|
||||
hintWidth := lipgloss.Width(helpHint)
|
||||
gap := m.width - promptWidth - hintWidth
|
||||
if gap > 0 {
|
||||
promptLine += strings.Repeat(" ", gap) + helpHint
|
||||
}
|
||||
|
||||
// Calculate layout
|
||||
listHeight := m.visibleLines()
|
||||
// listWidth is content area minus 1 for padding before border
|
||||
@@ -506,7 +888,7 @@ func (m model) View() 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
|
||||
@@ -522,6 +904,11 @@ func (m model) View() string {
|
||||
line := m.lines[idx]
|
||||
|
||||
var lineText string
|
||||
isSelected := lineIdx == m.cursor
|
||||
|
||||
// Full width including the padding space before border
|
||||
fullWidth := listWidth + 1
|
||||
|
||||
if m.config.ShowLineNums {
|
||||
// Calculate widths without ANSI codes first
|
||||
lineNumStr := fmt.Sprintf("%*d ", m.config.LineNumWidth, line.Number)
|
||||
@@ -531,20 +918,39 @@ func (m model) View() string {
|
||||
// Truncate content (no ANSI codes yet)
|
||||
content := truncateToWidth(line.Content, contentWidth)
|
||||
|
||||
// Now apply styling
|
||||
lineText = lineNumStyle.Render(lineNumStr) + content
|
||||
if isSelected {
|
||||
// For selected line: gray line number + black content, both on white background
|
||||
selectedLineNumStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("15")).
|
||||
Foreground(lipgloss.Color("241"))
|
||||
selectedContentStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("15")).
|
||||
Foreground(lipgloss.Color("#000000")).
|
||||
Bold(true)
|
||||
|
||||
// Pad content to fill remaining width
|
||||
contentPadded := content
|
||||
padding := fullWidth - lineNumWidth - lipgloss.Width(content)
|
||||
if padding > 0 {
|
||||
contentPadded = content + strings.Repeat(" ", padding)
|
||||
}
|
||||
lineText = selectedLineNumStyle.Render(lineNumStr) + selectedContentStyle.Render(contentPadded)
|
||||
} else {
|
||||
// Normal line - style line numbers differently
|
||||
lineText = lineNumStyle.Render(lineNumStr) + content
|
||||
}
|
||||
} else {
|
||||
// No line numbers, just truncate content
|
||||
lineText = truncateToWidth(line.Content, listWidth)
|
||||
}
|
||||
|
||||
if lineIdx == m.cursor {
|
||||
// Pad to full width for selection highlight
|
||||
padding := listWidth - lipgloss.Width(lineText)
|
||||
if padding > 0 {
|
||||
lineText = lineText + strings.Repeat(" ", padding)
|
||||
if isSelected {
|
||||
// Pad to full width for selection highlight
|
||||
padding := fullWidth - lipgloss.Width(lineText)
|
||||
if padding > 0 {
|
||||
lineText += strings.Repeat(" ", padding)
|
||||
}
|
||||
lineText = selectedStyle.Render(lineText)
|
||||
}
|
||||
lineText = selectedStyle.Render(lineText)
|
||||
}
|
||||
|
||||
listLines = append(listLines, lineText)
|
||||
@@ -583,8 +989,7 @@ func (m model) View() string {
|
||||
// Top border (no junction - vertical split starts at header separator)
|
||||
lines = append(lines, hLine(topLeft, topRight, 0))
|
||||
|
||||
// Header lines
|
||||
lines = append(lines, padLine(header))
|
||||
// Header line (command only)
|
||||
lines = append(lines, padLine(commandLine))
|
||||
|
||||
// Separator between header and content (T junction if vertical split)
|
||||
@@ -593,7 +998,7 @@ func (m model) View() 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 {
|
||||
@@ -624,7 +1029,7 @@ func (m model) View() 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 {
|
||||
@@ -633,7 +1038,7 @@ func (m model) View() 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 {
|
||||
@@ -689,7 +1094,7 @@ func (m model) View() string {
|
||||
}
|
||||
|
||||
// Build combined lines
|
||||
for i := 0; i < listHeight; i++ {
|
||||
for i := range listHeight {
|
||||
var leftContent, rightContent string
|
||||
var leftIsPreview, rightIsPreview bool
|
||||
|
||||
@@ -732,7 +1137,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,8 +253,8 @@ func TestVisibleLines(t *testing.T) {
|
||||
m := initialModel(cfg)
|
||||
m.height = 100
|
||||
|
||||
// Fixed lines: top border (1) + header (2) + separator (1) + bottom border (1) + prompt (1) = 6
|
||||
fixedLines := 6
|
||||
// Fixed lines: top border (1) + header (1) + separator (1) + bottom border (1) + prompt (1) = 5
|
||||
fixedLines := 5
|
||||
|
||||
// Without preview
|
||||
m.showPreview = false
|
||||
|
||||
10
main.go
10
main.go
@@ -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")
|
||||
@@ -55,6 +56,7 @@ func main() {
|
||||
_, _ = fmt.Fprintf(w, " / Enter filter mode\n")
|
||||
_, _ = fmt.Fprintf(w, " Esc Exit filter mode / clear filter\n")
|
||||
_, _ = fmt.Fprintf(w, " y Yank (copy) selected line\n")
|
||||
_, _ = fmt.Fprintf(w, " ? Show help overlay\n")
|
||||
}
|
||||
|
||||
flag.Usage = func() {
|
||||
@@ -106,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, "%")
|
||||
@@ -127,7 +130,8 @@ func main() {
|
||||
ShowLineNums: showLineNums,
|
||||
LineNumWidth: lineNumWidth,
|
||||
Prompt: prompt,
|
||||
RefreshSeconds: refreshSeconds,
|
||||
RefreshInterval: refreshInterval,
|
||||
Interactive: interactive,
|
||||
}
|
||||
|
||||
if err := ui.Run(uiConfig); err != nil {
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.2.0
|
||||
1.5.1
|
||||
|
||||
Reference in New Issue
Block a user