mirror of
https://github.com/chenasraf/watchr.git
synced 2026-05-18 01:29:05 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70aa9a9ee2 | ||
| f520a8b4ed | |||
| 63b45309b7 | |||
| 10a92082b6 | |||
| 8aaf5148ab | |||
| 347ac34094 | |||
| c9cec52c78 | |||
| 1f89f76e74 | |||
|
|
67de2606cd | ||
| c09d3e41dd | |||
| b8be28d92b | |||
| 69f8ed1ef0 | |||
|
|
d130becd99 | ||
| 5703a61ddb | |||
|
|
ca06d0d7c1 | ||
| f15b9e2559 | |||
| 7be6a03d6d |
0
.editorconfig
Normal file → Executable file
0
.editorconfig
Normal file → Executable file
0
.github/FUNDING.yml
vendored
Normal file → Executable file
0
.github/FUNDING.yml
vendored
Normal file → Executable file
42
.github/workflows/manual-homebrew-release.yml
vendored
Normal file → Executable file
42
.github/workflows/manual-homebrew-release.yml
vendored
Normal file → Executable file
@@ -3,40 +3,10 @@ name: Manual Homebrew Release
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release-homebrew:
|
||||
name: Trigger Homebrew Formula Update
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get latest tag
|
||||
id: latest
|
||||
run: |
|
||||
tag=$(gh release view --json tagName -q .tagName)
|
||||
echo "Latest release tag: $tag"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Send dispatch to homebrew-tap
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.REPO_DISPATCH_PAT }}
|
||||
run: |
|
||||
tag="${{ steps.latest.outputs.tag }}"
|
||||
repo="${{ github.event.repository.name }}"
|
||||
data="{\"event_type\":\"trigger-from-release\",\"client_payload\":{\"tag\":\"$tag\",\"repo\":\"$repo\"}}"
|
||||
echo "Dispatching tag $tag from $repo"
|
||||
echo "Data: $data"
|
||||
curl -X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/repos/chenasraf/homebrew-tap/dispatches \
|
||||
-d "$data"
|
||||
echo "Dispatched tag $tag from $repo"
|
||||
echo "Created job on https://github.com/chenasraf/homebrew-tap/actions"
|
||||
homebrew:
|
||||
uses: chenasraf/workflows/.github/workflows/manual-homebrew-release.yml@master
|
||||
with:
|
||||
homebrew-tap-repo: chenasraf/homebrew-tap
|
||||
secrets:
|
||||
REPO_DISPATCH_PAT: ${{ secrets.REPO_DISPATCH_PAT }}
|
||||
|
||||
118
.github/workflows/release.yml
vendored
Normal file → Executable file
118
.github/workflows/release.yml
vendored
Normal file → Executable file
@@ -11,114 +11,10 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.23'
|
||||
- name: Test
|
||||
run: go test -v ./...
|
||||
|
||||
generate:
|
||||
name: Build for ${{ matrix.platform }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
label: linux-amd64
|
||||
- platform: darwin/amd64
|
||||
label: darwin-amd64
|
||||
- platform: darwin/arm64
|
||||
label: darwin-arm64
|
||||
- platform: windows/amd64
|
||||
label: windows-amd64
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build for ${{ matrix.label }}
|
||||
uses: chenasraf/go-cross-build@v1
|
||||
with:
|
||||
platforms: ${{ matrix.platform }}
|
||||
package: ''
|
||||
name: 'watchr'
|
||||
compress: 'true'
|
||||
dest: dist
|
||||
|
||||
- name: Upload build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: "dist-${{ matrix.label }}"
|
||||
path: dist
|
||||
|
||||
release-please:
|
||||
name: Release
|
||||
if: github.ref == 'refs/heads/master'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
needs:
|
||||
- test
|
||||
- generate
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all builds
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
|
||||
- name: Verify Release Artifacts
|
||||
run: |
|
||||
ls -la dist
|
||||
for i in "linux-amd64" "darwin-amd64" "windows-amd64" "darwin-arm64"; do
|
||||
if [[ ! -f ./dist/dist-$i/watchr-$i.tar.gz ]]; then
|
||||
echo "File not found: ./dist/dist-$i/watchr-$i.tar.gz"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Run Release Please
|
||||
uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
release-type: simple
|
||||
|
||||
- name: Upload Release Artifacts
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
for i in "linux-amd64" "darwin-amd64" "darwin-arm64" "windows-amd64"; do
|
||||
gh release upload "${{ steps.release.outputs.tag_name }}" "./dist/dist-$i/watchr-$i.tar.gz"
|
||||
done
|
||||
|
||||
release-homebrew:
|
||||
name: Homebrew Release
|
||||
needs: [release-please]
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send dispatch to homebrew-tap
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.REPO_DISPATCH_PAT }}
|
||||
run: |
|
||||
repo="${{ github.event.repository.name }}"
|
||||
tag="${{ needs.release-please.outputs.tag_name }}"
|
||||
data="{\"event_type\":\"trigger-from-release\",\"client_payload\":{\"tag\":\"$tag\",\"repo\":\"$repo\"}}"
|
||||
echo "Dispatching tag $tag from $repo"
|
||||
echo "Data: $data"
|
||||
curl -X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/repos/chenasraf/homebrew-tap/dispatches \
|
||||
-d "$data"
|
||||
echo "Dispatched tag $tag from $repo"
|
||||
echo "Created job on https://github.com/chenasraf/homebrew-tap/actions"
|
||||
release:
|
||||
uses: chenasraf/workflows/.github/workflows/go-release.yml@master
|
||||
with:
|
||||
name: watchr
|
||||
homebrew-tap-repo: chenasraf/homebrew-tap
|
||||
secrets:
|
||||
REPO_DISPATCH_PAT: ${{ secrets.REPO_DISPATCH_PAT }}
|
||||
|
||||
0
.github/workflows/test.yml
vendored
Normal file → Executable file
0
.github/workflows/test.yml
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
0
.golangci.yml
Normal file → Executable file
0
.golangci.yml
Normal file → Executable file
0
.prettierrc
Normal file → Executable file
0
.prettierrc
Normal file → Executable file
36
CHANGELOG.md
Normal file → Executable file
36
CHANGELOG.md
Normal file → Executable file
@@ -1,5 +1,41 @@
|
||||
# Changelog
|
||||
|
||||
## [1.6.0](https://github.com/chenasraf/watchr/compare/v1.5.2...v1.6.0) (2026-01-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add refresh countdown timer ([63b4530](https://github.com/chenasraf/watchr/commit/63b45309b76def1b5be9c11d5228d97bb7ab0a6d))
|
||||
* add refresh-from-start setting ([f520a8b](https://github.com/chenasraf/watchr/commit/f520a8b4ed665c3187bab88707df9e5efdc779bc))
|
||||
* reset refresh timer after manual refresh ([10a9208](https://github.com/chenasraf/watchr/commit/10a92082b6c11fcfba2bd835ed2336d1f33d1c04))
|
||||
* stream new output in-place without resetting existing output ([8aaf514](https://github.com/chenasraf/watchr/commit/8aaf5148ab17b33a72ff3f946c57029c33bdb4d0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* streaming cursor position ([347ac34](https://github.com/chenasraf/watchr/commit/347ac340942c3ee8d2731432f808ac2b9dd39060))
|
||||
|
||||
## [1.5.2](https://github.com/chenasraf/watchr/compare/v1.5.1...v1.5.2) (2026-01-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* tab characters breaking the layout ([c09d3e4](https://github.com/chenasraf/watchr/commit/c09d3e41ddd69c44ea0546cb0608abe8b0c50334))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
1
Makefile
Normal file → Executable file
1
Makefile
Normal file → Executable file
@@ -48,7 +48,6 @@ precommit:
|
||||
echo "Running pre-commit checks..."; \
|
||||
echo "go fmt"; \
|
||||
go fmt ./...; \
|
||||
git add $$STAGED_FILES; \
|
||||
echo "go vet"; \
|
||||
go vet ./...; \
|
||||
echo "golangci-lint"; \
|
||||
|
||||
30
README.md
Normal file → Executable file
30
README.md
Normal file → Executable file
@@ -7,6 +7,8 @@ provides vim-style navigation, filtering, and a preview pane—all without leavi
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🚀 Features
|
||||
@@ -76,6 +78,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 +104,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 +138,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 +150,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 +163,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):
|
||||
|
||||
81
internal/config/config.go
Normal file → Executable file
81
internal/config/config.go
Normal file → Executable file
@@ -4,7 +4,10 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
@@ -12,14 +15,15 @@ import (
|
||||
|
||||
// Config keys
|
||||
const (
|
||||
KeyShell = "shell"
|
||||
KeyPreviewSize = "preview-size"
|
||||
KeyPreviewPosition = "preview-position"
|
||||
KeyLineNumbers = "line-numbers"
|
||||
KeyLineWidth = "line-width"
|
||||
KeyPrompt = "prompt"
|
||||
KeyRefresh = "refresh"
|
||||
KeyInteractive = "interactive"
|
||||
KeyShell = "shell"
|
||||
KeyPreviewSize = "preview-size"
|
||||
KeyPreviewPosition = "preview-position"
|
||||
KeyLineNumbers = "line-numbers"
|
||||
KeyLineWidth = "line-width"
|
||||
KeyPrompt = "prompt"
|
||||
KeyRefresh = "refresh"
|
||||
KeyRefreshFromStart = "refresh-from-start"
|
||||
KeyInteractive = "interactive"
|
||||
)
|
||||
|
||||
// setDefaults sets the default configuration values.
|
||||
@@ -30,7 +34,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(KeyRefreshFromStart, false)
|
||||
viper.SetDefault(KeyInteractive, false)
|
||||
}
|
||||
|
||||
@@ -76,6 +81,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(KeyRefreshFromStart, flags.Lookup("refresh-from-start"))
|
||||
_ = viper.BindPFlag(KeyInteractive, flags.Lookup("interactive"))
|
||||
|
||||
// line-numbers is inverted (no-line-numbers flag)
|
||||
@@ -129,7 +135,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", KeyRefreshFromStart+":", GetBool(KeyRefreshFromStart))
|
||||
fmt.Printf(" %-20s %v\n", KeyInteractive+":", GetBool(KeyInteractive))
|
||||
}
|
||||
|
||||
@@ -149,3 +156,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
|
||||
}
|
||||
|
||||
192
internal/config/config_test.go
Normal file → Executable file
192
internal/config/config_test.go
Normal file → Executable 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
67
internal/runner/runner.go
Normal file → Executable file
67
internal/runner/runner.go
Normal file → Executable file
@@ -12,6 +12,15 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// sanitizeLine removes control sequences that can corrupt terminal rendering
|
||||
func sanitizeLine(s string) string {
|
||||
// Remove carriage returns
|
||||
s = strings.ReplaceAll(s, "\r", "")
|
||||
// Convert tabs to spaces (tabs cause width calculation issues)
|
||||
s = strings.ReplaceAll(s, "\t", " ")
|
||||
return s
|
||||
}
|
||||
|
||||
// Line represents a single line of output with its line number
|
||||
type Line struct {
|
||||
Number int
|
||||
@@ -141,7 +150,7 @@ func (r *Runner) Run(ctx context.Context) (Result, error) {
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, Line{
|
||||
Number: lineNum,
|
||||
Content: scanner.Text(),
|
||||
Content: sanitizeLine(scanner.Text()),
|
||||
})
|
||||
lineNum++
|
||||
}
|
||||
@@ -151,7 +160,7 @@ func (r *Runner) Run(ctx context.Context) (Result, error) {
|
||||
for stderrScanner.Scan() {
|
||||
lines = append(lines, Line{
|
||||
Number: lineNum,
|
||||
Content: stderrScanner.Text(),
|
||||
Content: sanitizeLine(stderrScanner.Text()),
|
||||
})
|
||||
lineNum++
|
||||
}
|
||||
@@ -169,11 +178,13 @@ func (r *Runner) Run(ctx context.Context) (Result, error) {
|
||||
|
||||
// StreamingResult holds the state of a streaming command
|
||||
type StreamingResult struct {
|
||||
Lines *[]Line
|
||||
ExitCode int
|
||||
Done bool
|
||||
Error error
|
||||
mu sync.RWMutex
|
||||
Lines *[]Line
|
||||
ExitCode int
|
||||
Done bool
|
||||
Error error
|
||||
PrevLineCount int // Number of lines from previous run (for trimming)
|
||||
CurrentLineCount int // Number of lines written by current run
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// GetLines returns a copy of the current lines (thread-safe)
|
||||
@@ -205,14 +216,27 @@ func (s *StreamingResult) IsDone() bool {
|
||||
return s.Done
|
||||
}
|
||||
|
||||
// GetCurrentLineCount returns the number of lines written by the current run (thread-safe)
|
||||
func (s *StreamingResult) GetCurrentLineCount() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.CurrentLineCount
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// If prevLines is provided, lines are updated in place rather than starting fresh.
|
||||
func (r *Runner) RunStreaming(ctx context.Context, prevLines []Line) *StreamingResult {
|
||||
// Copy previous lines to allow in-place updates
|
||||
lines := make([]Line, len(prevLines))
|
||||
copy(lines, prevLines)
|
||||
|
||||
result := &StreamingResult{
|
||||
Lines: &[]Line{},
|
||||
ExitCode: -1,
|
||||
Done: false,
|
||||
Lines: &lines,
|
||||
ExitCode: -1,
|
||||
Done: false,
|
||||
PrevLineCount: len(prevLines),
|
||||
}
|
||||
|
||||
go func() {
|
||||
@@ -259,14 +283,27 @@ func (r *Runner) RunStreaming(ctx context.Context) *StreamingResult {
|
||||
for scanner.Scan() {
|
||||
lineNumMu.Lock()
|
||||
currentLineNum := lineNum
|
||||
lineIdx := lineNum - 1 // 0-indexed
|
||||
lineNum++
|
||||
lineNumMu.Unlock()
|
||||
|
||||
result.mu.Lock()
|
||||
*result.Lines = append(*result.Lines, Line{
|
||||
newLine := Line{
|
||||
Number: currentLineNum,
|
||||
Content: scanner.Text(),
|
||||
})
|
||||
Content: sanitizeLine(scanner.Text()),
|
||||
}
|
||||
|
||||
result.mu.Lock()
|
||||
if lineIdx < len(*result.Lines) {
|
||||
// Update existing line in place
|
||||
(*result.Lines)[lineIdx] = newLine
|
||||
} else {
|
||||
// Append new line
|
||||
*result.Lines = append(*result.Lines, newLine)
|
||||
}
|
||||
// Track how many lines this run has produced
|
||||
if currentLineNum > result.CurrentLineCount {
|
||||
result.CurrentLineCount = currentLineNum
|
||||
}
|
||||
result.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
58
internal/runner/runner_test.go
Normal file → Executable file
58
internal/runner/runner_test.go
Normal file → Executable file
@@ -320,3 +320,61 @@ func TestSplitLines(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "plain text unchanged",
|
||||
input: "hello world",
|
||||
want: "hello world",
|
||||
},
|
||||
{
|
||||
name: "tabs converted to spaces",
|
||||
input: "col1\tcol2\tcol3",
|
||||
want: "col1 col2 col3",
|
||||
},
|
||||
{
|
||||
name: "carriage returns removed",
|
||||
input: "line with\r\nwindows ending",
|
||||
want: "line with\nwindows ending",
|
||||
},
|
||||
{
|
||||
name: "carriage return only removed",
|
||||
input: "progress\roverwrite",
|
||||
want: "progressoverwrite",
|
||||
},
|
||||
{
|
||||
name: "ANSI color codes preserved",
|
||||
input: "\x1b[32mgreen text\x1b[0m",
|
||||
want: "\x1b[32mgreen text\x1b[0m",
|
||||
},
|
||||
{
|
||||
name: "mixed tabs and colors",
|
||||
input: "\x1b[1m?\x1b[0m\tpackage\t[no test files]",
|
||||
want: "\x1b[1m?\x1b[0m package [no test files]",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "multiple tabs",
|
||||
input: "\t\t\t",
|
||||
want: " ",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := sanitizeLine(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("sanitizeLine(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
189
internal/ui/ui.go
Normal file → Executable file
189
internal/ui/ui.go
Normal file → Executable file
@@ -33,34 +33,38 @@ type Config struct {
|
||||
ShowLineNums bool
|
||||
LineNumWidth int
|
||||
Prompt string
|
||||
RefreshSeconds int
|
||||
RefreshInterval time.Duration
|
||||
RefreshFromStart bool // If true, refresh timer starts when command starts; if false, when command ends (default)
|
||||
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
|
||||
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
|
||||
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
|
||||
userScrolled bool // true if user manually scrolled during streaming
|
||||
refreshGeneration int // incremented on manual refresh to reset timer
|
||||
refreshStartTime time.Time // when the refresh timer was started
|
||||
spinnerFrame int // current spinner animation frame
|
||||
errorMsg string
|
||||
statusMsg string // temporary status message (e.g., "Yanked!")
|
||||
exitCode int // last command exit code
|
||||
}
|
||||
|
||||
// messages
|
||||
@@ -69,11 +73,16 @@ type resultMsg struct {
|
||||
exitCode int
|
||||
}
|
||||
type errMsg struct{ err error }
|
||||
type tickMsg time.Time
|
||||
type tickMsg struct {
|
||||
generation int
|
||||
}
|
||||
type clearStatusMsg struct{}
|
||||
type spinnerTickMsg time.Time
|
||||
type streamTickMsg time.Time // periodic check for streaming updates
|
||||
type startStreamMsg struct{} // trigger to start streaming
|
||||
type streamTickMsg time.Time // periodic check for streaming updates
|
||||
type startStreamMsg struct{} // trigger to start streaming
|
||||
type countdownTickMsg struct { // periodic update for refresh countdown display
|
||||
generation int
|
||||
}
|
||||
|
||||
// Spinner frames for the loading animation
|
||||
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
@@ -149,6 +158,13 @@ func (m model) streamTickCmd() tea.Cmd {
|
||||
})
|
||||
}
|
||||
|
||||
func (m model) countdownTickCmd() tea.Cmd {
|
||||
gen := m.refreshGeneration
|
||||
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||
return countdownTickMsg{generation: gen}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *model) startStreaming() tea.Cmd {
|
||||
// Cancel any existing context and create a new one
|
||||
if m.cancel != nil {
|
||||
@@ -156,14 +172,27 @@ func (m *model) startStreaming() tea.Cmd {
|
||||
}
|
||||
m.ctx, m.cancel = context.WithCancel(context.Background())
|
||||
|
||||
m.streamResult = m.runner.RunStreaming(m.ctx)
|
||||
// Pass previous lines for in-place updates
|
||||
m.streamResult = m.runner.RunStreaming(m.ctx, m.lines)
|
||||
m.streaming = true
|
||||
m.loading = true
|
||||
m.lastLineCount = 0
|
||||
m.lastLineCount = len(m.lines)
|
||||
m.exitCode = -1
|
||||
m.errorMsg = ""
|
||||
m.userScrolled = false
|
||||
|
||||
return m.streamTickCmd()
|
||||
cmds := []tea.Cmd{m.streamTickCmd()}
|
||||
|
||||
// Start refresh timer from command start if configured
|
||||
if m.config.RefreshFromStart && m.config.RefreshInterval > 0 {
|
||||
m.refreshStartTime = time.Now()
|
||||
cmds = append(cmds, m.tickCmd())
|
||||
if m.config.RefreshInterval > time.Second {
|
||||
cmds = append(cmds, m.countdownTickCmd())
|
||||
}
|
||||
}
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -201,6 +230,15 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.lines = newLines
|
||||
m.lastLineCount = newCount
|
||||
m.updateFiltered()
|
||||
|
||||
// Auto-scroll to bottom if user hasn't manually scrolled
|
||||
if !m.userScrolled {
|
||||
visible := m.visibleLines()
|
||||
if visible > 0 {
|
||||
m.cursor = max(len(m.filtered)-1, 0)
|
||||
m.offset = max(len(m.filtered)-visible, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if command completed
|
||||
@@ -212,9 +250,22 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.errorMsg = m.streamResult.Error.Error()
|
||||
}
|
||||
|
||||
// If auto-refresh is enabled, schedule the next run
|
||||
if m.config.RefreshSeconds > 0 {
|
||||
return m, m.tickCmd()
|
||||
// Trim excess lines from previous run
|
||||
currentCount := m.streamResult.GetCurrentLineCount()
|
||||
if currentCount < len(m.lines) {
|
||||
m.lines = m.lines[:currentCount]
|
||||
m.updateFiltered()
|
||||
}
|
||||
|
||||
// If auto-refresh is enabled and timer starts from end, schedule the next run
|
||||
if m.config.RefreshInterval > 0 && !m.config.RefreshFromStart {
|
||||
m.refreshStartTime = time.Now()
|
||||
cmds := []tea.Cmd{m.tickCmd()}
|
||||
// Start countdown display updates if interval > 1s
|
||||
if m.config.RefreshInterval > time.Second {
|
||||
cmds = append(cmds, m.countdownTickCmd())
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -223,7 +274,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, m.streamTickCmd()
|
||||
|
||||
case tickMsg:
|
||||
if m.config.RefreshSeconds > 0 && !m.streaming {
|
||||
// Ignore ticks from before a manual refresh
|
||||
if msg.generation != m.refreshGeneration {
|
||||
return m, nil
|
||||
}
|
||||
if m.config.RefreshInterval > 0 && !m.streaming {
|
||||
// Restart streaming for refresh
|
||||
cmd := m.startStreaming()
|
||||
return m, tea.Batch(cmd, m.spinnerTickCmd())
|
||||
@@ -246,14 +301,29 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, m.spinnerTickCmd()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case countdownTickMsg:
|
||||
// Ignore ticks from before a manual refresh
|
||||
if msg.generation != m.refreshGeneration {
|
||||
return m, nil
|
||||
}
|
||||
// Continue ticking if waiting for auto-refresh
|
||||
if m.config.RefreshInterval > time.Second && !m.streaming && !m.refreshStartTime.IsZero() {
|
||||
elapsed := time.Since(m.refreshStartTime)
|
||||
if elapsed < m.config.RefreshInterval {
|
||||
return m, m.countdownTickCmd()
|
||||
}
|
||||
}
|
||||
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 tickMsg(t)
|
||||
gen := m.refreshGeneration
|
||||
return tea.Tick(m.config.RefreshInterval, func(t time.Time) tea.Msg {
|
||||
return tickMsg{generation: gen}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -295,35 +365,53 @@ 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
|
||||
|
||||
case "j", "down", "ctrl+n":
|
||||
m.userScrolled = true
|
||||
m.moveCursor(1)
|
||||
case "k", "up", "ctrl+p":
|
||||
m.userScrolled = true
|
||||
m.moveCursor(-1)
|
||||
case "g", "home":
|
||||
m.userScrolled = true
|
||||
m.cursor = 0
|
||||
m.offset = 0
|
||||
case "G", "end":
|
||||
m.userScrolled = false // Resume following output
|
||||
if len(m.filtered) > 0 {
|
||||
m.cursor = len(m.filtered) - 1
|
||||
m.adjustOffset()
|
||||
}
|
||||
case "ctrl+d":
|
||||
m.userScrolled = true
|
||||
m.moveCursor(m.visibleLines() / 2)
|
||||
case "ctrl+u":
|
||||
m.userScrolled = true
|
||||
m.moveCursor(-m.visibleLines() / 2)
|
||||
case "pgdown", "ctrl+f":
|
||||
m.userScrolled = true
|
||||
m.moveCursor(m.visibleLines())
|
||||
case "pgup", "ctrl+b":
|
||||
m.userScrolled = true
|
||||
m.moveCursor(-m.visibleLines())
|
||||
case "p":
|
||||
m.showPreview = !m.showPreview
|
||||
m.adjustOffset() // Keep selected line visible after preview toggle
|
||||
case "r", "ctrl+r":
|
||||
// Restart streaming
|
||||
// Restart streaming and reset auto-refresh timer
|
||||
m.refreshGeneration++
|
||||
cmd := m.startStreaming()
|
||||
return m, tea.Batch(cmd, m.spinnerTickCmd())
|
||||
case "/":
|
||||
@@ -485,7 +573,16 @@ func (m *model) updateFiltered() {
|
||||
if m.cursor < 0 {
|
||||
m.cursor = 0
|
||||
}
|
||||
m.offset = 0
|
||||
|
||||
// Clamp offset to valid bounds instead of resetting to 0
|
||||
// This preserves scroll position during streaming updates
|
||||
visible := m.visibleLines()
|
||||
if visible > 0 {
|
||||
maxOffset := max(len(m.filtered)-visible, 0)
|
||||
if m.offset > maxOffset {
|
||||
m.offset = maxOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// renderHelpOverlay creates the help box content (without positioning)
|
||||
@@ -837,6 +934,22 @@ func (m model) renderMainView() string {
|
||||
commandLine = prefix + failStyle.Render(fmt.Sprintf("✗ [%d] %s", m.exitCode, m.config.Command))
|
||||
}
|
||||
|
||||
// Add refresh countdown on the right if auto-refresh is enabled and > 1s
|
||||
if m.config.RefreshInterval > time.Second && !m.streaming && !m.refreshStartTime.IsZero() {
|
||||
elapsed := time.Since(m.refreshStartTime)
|
||||
remaining := m.config.RefreshInterval - elapsed
|
||||
if remaining > 0 {
|
||||
countdownStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) // dim gray
|
||||
countdown := countdownStyle.Render(fmt.Sprintf("(%ds)", int(remaining.Seconds())+1))
|
||||
cmdWidth := lipgloss.Width(commandLine)
|
||||
countdownWidth := lipgloss.Width(countdown)
|
||||
gap := innerWidth - cmdWidth - countdownWidth
|
||||
if gap > 0 {
|
||||
commandLine += strings.Repeat(" ", gap) + countdown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build prompt line (will go at bottom)
|
||||
var promptLine string
|
||||
switch {
|
||||
|
||||
7
internal/ui/ui_test.go
Normal file → Executable file
7
internal/ui/ui_test.go
Normal file → Executable 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
9
main.go
Normal file → Executable file
9
main.go
Normal file → Executable 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.Bool("refresh-from-start", false, "Start refresh timer when command starts (default: when command ends)")
|
||||
flag.BoolP("interactive", "i", false, "Run shell in interactive mode (sources ~/.bashrc, ~/.zshrc, etc.)")
|
||||
|
||||
printUsage := func(w *os.File) {
|
||||
@@ -108,7 +109,8 @@ 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)
|
||||
refreshFromStart := config.GetBool(config.KeyRefreshFromStart)
|
||||
showLineNums := config.ShowLineNumbers()
|
||||
interactive := config.GetBool(config.KeyInteractive)
|
||||
|
||||
@@ -130,7 +132,8 @@ func main() {
|
||||
ShowLineNums: showLineNums,
|
||||
LineNumWidth: lineNumWidth,
|
||||
Prompt: prompt,
|
||||
RefreshSeconds: refreshSeconds,
|
||||
RefreshInterval: refreshInterval,
|
||||
RefreshFromStart: refreshFromStart,
|
||||
Interactive: interactive,
|
||||
}
|
||||
|
||||
|
||||
2
version.txt
Normal file → Executable file
2
version.txt
Normal file → Executable file
@@ -1 +1 @@
|
||||
1.4.0
|
||||
1.6.0
|
||||
|
||||
Reference in New Issue
Block a user