From d27cefcfd4891fa23f770eac9c220c2574371bff Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Wed, 3 Dec 2025 14:17:51 +0200 Subject: [PATCH] feat: initial version --- .github/FUNDING.yml | 13 + .github/workflows/manual-homebrew-release.yml | 42 ++ .github/workflows/release.yml | 124 +++++ .github/workflows/test.yml | 44 ++ .gitignore | 1 + Makefile | 45 ++ go.mod | 31 ++ go.sum | 45 ++ internal/runner/runner.go | 158 +++++++ internal/runner/runner_test.go | 223 +++++++++ internal/ui/ui.go | 430 ++++++++++++++++++ internal/ui/ui_test.go | 260 +++++++++++ main.go | 93 ++++ version.txt | 1 + 14 files changed, 1510 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/manual-homebrew-release.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/runner/runner.go create mode 100644 internal/runner/runner_test.go create mode 100644 internal/ui/ui.go create mode 100644 internal/ui/ui_test.go create mode 100644 main.go create mode 100644 version.txt diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..6be5fe8 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,13 @@ +# These are supported funding model platforms + +github: chenasraf +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: casraf +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: + - "https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=TSH3C3ABGQM22¤cy_code=ILS&source=url" diff --git a/.github/workflows/manual-homebrew-release.yml b/.github/workflows/manual-homebrew-release.yml new file mode 100644 index 0000000..70556b9 --- /dev/null +++ b/.github/workflows/manual-homebrew-release.yml @@ -0,0 +1,42 @@ +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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e4e8ba3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,124 @@ +name: Release + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +permissions: + contents: write + 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" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1201c89 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,44 @@ +name: Test + +on: + push: + branches: + - develop + pull_request: + branches: + - master + +jobs: + build: + name: Build & 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: Build + run: go build -v + + - name: Test + run: go test -v ./... + + - name: Create dist/ dir + run: mkdir dist + + - name: Generate build files + uses: chenasraf/go-cross-build@v1 + with: + platforms: 'linux/amd64, darwin/amd64, windows/amd64' # , darwin/arm64' # ' + package: '' + name: 'watchr' + compress: 'true' + dest: 'dist' + - name: Upload builds + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4280c71 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +watchr diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e0812d7 --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +all: run + +.PHONY: build +build: + go build + +.PHONY: run +run: build + ./watchr + +.PHONY: test +test: + go test -v ./... + +.PHONY: install +install: build + cp watchr ~/.local/bin + +.PHONY: uninstall +uninstall: + rm -f ~/.local/bin/watchr + +.PHONY: precommit-install +precommit-install: + @echo "Installing pre-commit hooks..." + @echo "#!/bin/sh\n\nmake precommit" > .git/hooks/pre-commit + @chmod +x .git/hooks/pre-commit + @echo "Pre-commit hooks installed." + +.PHONY: precommit +precommit: + @STAGED_FILES=$$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.go$$'); \ + if [ -z "$$STAGED_FILES" ]; then \ + echo "No staged Go files to check."; \ + else \ + echo "Running pre-commit checks..."; \ + echo "go fmt"; \ + go fmt ./...; \ + echo "go vet"; \ + go vet ./...; \ + echo "golangci-lint"; \ + golangci-lint run ./...; \ + echo "go test"; \ + go test -v ./...; \ + fi diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..24b2e46 --- /dev/null +++ b/go.mod @@ -0,0 +1,31 @@ +module github.com/chenasraf/watchr + +go 1.24.0 + +toolchain go1.24.11 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/spf13/pflag v1.0.10 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..98071e3 --- /dev/null +++ b/go.sum @@ -0,0 +1,45 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/internal/runner/runner.go b/internal/runner/runner.go new file mode 100644 index 0000000..589b919 --- /dev/null +++ b/internal/runner/runner.go @@ -0,0 +1,158 @@ +package runner + +import ( + "bufio" + "context" + "fmt" + "io" + "os/exec" + "strings" + "sync" +) + +// Line represents a single line of output with its line number +type Line struct { + Number int + Content string +} + +// FormatLine returns the formatted line with line number +func (l Line) FormatLine(width int, showLineNum bool) string { + if !showLineNum { + return l.Content + } + return fmt.Sprintf("%*d %s", width, l.Number, l.Content) +} + +// Runner executes commands and captures output +type Runner struct { + Shell string + Command string +} + +// NewRunner creates a new Runner +func NewRunner(shell, command string) *Runner { + return &Runner{ + Shell: shell, + Command: command, + } +} + +// 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) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, 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) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("failed to start command: %w", err) + } + + var lines []Line + lineNum := 1 + + // Read stdout + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + lines = append(lines, Line{ + Number: lineNum, + Content: scanner.Text(), + }) + lineNum++ + } + + // Read stderr + stderrScanner := bufio.NewScanner(stderr) + for stderrScanner.Scan() { + lines = append(lines, Line{ + Number: lineNum, + Content: stderrScanner.Text(), + }) + 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() + } + } + + go readPipe(stdout) + go readPipe(stderr) + + wg.Wait() + + // Wait for command to finish (ignore exit code - we still want to show output) + _ = cmd.Wait() + + return nil +} + +// 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) + output, err := cmd.CombinedOutput() + if err != nil { + // Still return output even on error (non-zero exit) + if len(output) > 0 { + return splitLines(string(output)), nil + } + return nil, fmt.Errorf("command failed: %w", err) + } + return splitLines(string(output)), nil +} + +func splitLines(s string) []string { + s = strings.TrimSuffix(s, "\n") + if s == "" { + return []string{} + } + return strings.Split(s, "\n") +} diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go new file mode 100644 index 0000000..e21cc8a --- /dev/null +++ b/internal/runner/runner_test.go @@ -0,0 +1,223 @@ +package runner + +import ( + "context" + "testing" + "time" +) + +func TestNewRunner(t *testing.T) { + r := NewRunner("sh", "echo hello") + if r.Shell != "sh" { + t.Errorf("expected shell 'sh', got %q", r.Shell) + } + if r.Command != "echo hello" { + t.Errorf("expected command 'echo hello', got %q", r.Command) + } +} + +func TestRunner_Run(t *testing.T) { + tests := []struct { + name string + shell string + command string + wantLines int + wantContent string + }{ + { + name: "simple echo", + shell: "sh", + command: "echo hello", + wantLines: 1, + wantContent: "hello", + }, + { + name: "multiline output", + shell: "sh", + command: "echo 'line1\nline2\nline3'", + wantLines: 3, + wantContent: "line1", + }, + { + name: "empty output", + shell: "sh", + command: "true", + wantLines: 0, + wantContent: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewRunner(tt.shell, tt.command) + ctx := context.Background() + + lines, 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 tt.wantLines > 0 && lines[0].Content != tt.wantContent { + t.Errorf("expected first line %q, got %q", tt.wantContent, lines[0].Content) + } + }) + } +} + +func TestRunner_RunSimple(t *testing.T) { + r := NewRunner("sh", "echo 'hello world'") + ctx := context.Background() + + lines, err := r.RunSimple(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(lines) != 1 { + t.Fatalf("expected 1 line, got %d", len(lines)) + } + + if lines[0] != "hello world" { + t.Errorf("expected 'hello world', got %q", lines[0]) + } +} + +func TestRunner_RunWithContext(t *testing.T) { + r := NewRunner("sh", "sleep 10") + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + _, err := r.Run(ctx) + // The command should be killed by context timeout + if err == nil { + t.Log("command completed (may happen on fast systems)") + } +} + +func TestRunner_RunWithFailingCommand(t *testing.T) { + r := NewRunner("sh", "exit 1") + ctx := context.Background() + + // Should not return error for non-zero exit, just empty output + lines, 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)) + } +} + +func TestRunner_RunWithOutputAndError(t *testing.T) { + r := NewRunner("sh", "echo 'output'; exit 1") + ctx := context.Background() + + lines, 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 lines[0].Content != "output" { + t.Errorf("expected 'output', got %q", lines[0].Content) + } +} + +func TestLine_FormatLine(t *testing.T) { + tests := []struct { + name string + line Line + width int + showLineNum bool + want string + }{ + { + name: "with line number", + line: Line{Number: 1, Content: "hello"}, + width: 6, + showLineNum: true, + want: " 1 hello", + }, + { + name: "without line number", + line: Line{Number: 1, Content: "hello"}, + width: 6, + showLineNum: false, + want: "hello", + }, + { + name: "larger line number", + line: Line{Number: 123, Content: "test"}, + width: 6, + showLineNum: true, + want: " 123 test", + }, + { + name: "narrow width", + line: Line{Number: 1, Content: "content"}, + width: 3, + showLineNum: true, + want: " 1 content", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.line.FormatLine(tt.width, tt.showLineNum) + if got != tt.want { + t.Errorf("FormatLine() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestSplitLines(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + { + name: "single line", + input: "hello", + want: []string{"hello"}, + }, + { + name: "multiple lines", + input: "line1\nline2\nline3", + want: []string{"line1", "line2", "line3"}, + }, + { + name: "trailing newline", + input: "hello\n", + want: []string{"hello"}, + }, + { + name: "empty string", + input: "", + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := splitLines(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("splitLines() returned %d lines, want %d", len(got), len(tt.want)) + } + for i, line := range got { + if line != tt.want[i] { + t.Errorf("splitLines()[%d] = %q, want %q", i, line, tt.want[i]) + } + } + }) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..f88dda6 --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,430 @@ +package ui + +import ( + "context" + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/chenasraf/watchr/internal/runner" +) + +// PreviewPosition defines where the preview panel is displayed +type PreviewPosition string + +const ( + PreviewBottom PreviewPosition = "bottom" + PreviewTop PreviewPosition = "top" + PreviewLeft PreviewPosition = "left" + PreviewRight PreviewPosition = "right" +) + +// Config holds the UI configuration +type Config struct { + Command string + Shell string + PreviewHeight int + PreviewPosition PreviewPosition + ShowLineNums bool + LineNumWidth int + Prompt string + RefreshSeconds int +} + +// 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 +} + +// messages +type linesMsg []runner.Line +type errMsg struct{ err error } +type tickMsg time.Time + +func (e errMsg) Error() string { return e.err.Error() } + +func initialModel(cfg Config) model { + ctx, cancel := context.WithCancel(context.Background()) + return model{ + config: cfg, + lines: []runner.Line{}, + filtered: []int{}, + cursor: 0, + offset: 0, + filter: "", + filterMode: false, + showPreview: false, + runner: runner.NewRunner(cfg.Shell, cfg.Command), + 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 + return func() tea.Msg { + lines, err := r.Run(ctx) + if err != nil { + return errMsg{err} + } + return linesMsg(lines) + } +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.handleKeyPress(msg) + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case linesMsg: + m.lines = []runner.Line(msg) + m.loading = false + m.updateFiltered() + return m, nil + + case tickMsg: + if m.config.RefreshSeconds > 0 { + return m, tea.Batch( + m.runCommand(), + m.tickCmd(), + ) + } + return m, nil + + case errMsg: + m.errorMsg = msg.Error() + m.loading = false + 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) + }) +} + +func (m *model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // In filter mode, handle text input + if m.filterMode { + switch msg.Type { + case tea.KeyEsc: + m.filterMode = false + m.filter = "" + m.updateFiltered() + return m, nil + case tea.KeyEnter: + m.filterMode = false + return m, nil + case tea.KeyBackspace: + if len(m.filter) > 0 { + m.filter = m.filter[:len(m.filter)-1] + m.updateFiltered() + } + return m, nil + default: + if msg.Type == tea.KeyRunes { + m.filter += string(msg.Runes) + m.updateFiltered() + } + return m, nil + } + } + + // Normal mode keybindings + switch msg.String() { + case "q", "esc", "ctrl+c": + m.cancel() + return m, tea.Quit + + case "j", "down", "ctrl+n": + m.moveCursor(1) + case "k", "up", "ctrl+p": + m.moveCursor(-1) + case "g", "home": + m.cursor = 0 + m.offset = 0 + case "G", "end": + if len(m.filtered) > 0 { + m.cursor = len(m.filtered) - 1 + m.adjustOffset() + } + case "ctrl+d": + m.moveCursor(m.visibleLines() / 2) + case "ctrl+u": + m.moveCursor(-m.visibleLines() / 2) + case "pgdown", "ctrl+f": + m.moveCursor(m.visibleLines()) + case "pgup", "ctrl+b": + m.moveCursor(-m.visibleLines()) + case "p": + m.showPreview = !m.showPreview + m.adjustOffset() // Keep selected line visible after preview toggle + case "r", "ctrl+r": + m.loading = true + return m, m.runCommand() + case "/": + m.filterMode = true + m.filter = "" + } + + return m, nil +} + +func (m *model) moveCursor(delta int) { + m.cursor += delta + if m.cursor < 0 { + m.cursor = 0 + } + if m.cursor >= len(m.filtered) { + m.cursor = len(m.filtered) - 1 + } + if m.cursor < 0 { + m.cursor = 0 + } + m.adjustOffset() +} + +func (m *model) adjustOffset() { + visible := m.visibleLines() + if visible <= 0 { + return + } + + // Try to center the cursor + 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 + } + + m.offset = idealOffset +} + +func (m model) visibleLines() int { + // header (1) + command (1) + prompt at bottom (1) = 3 fixed lines + fixedLines := 3 + if m.showPreview && (m.config.PreviewPosition == PreviewTop || m.config.PreviewPosition == PreviewBottom) { + previewHeight := m.height * m.config.PreviewHeight / 100 + return m.height - fixedLines - previewHeight + } + return m.height - fixedLines +} + +func (m *model) updateFiltered() { + m.filtered = []int{} + + filter := strings.ToLower(m.filter) + for i, line := range m.lines { + if m.filter == "" || strings.Contains(strings.ToLower(line.Content), filter) { + m.filtered = append(m.filtered, i) + } + } + + // Reset cursor if out of bounds + if m.cursor >= len(m.filtered) { + m.cursor = len(m.filtered) - 1 + } + if m.cursor < 0 { + m.cursor = 0 + } + m.offset = 0 +} + +func (m model) View() string { + if m.width == 0 || m.height == 0 { + return "Loading..." + } + + // Styles + headerStyle := 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")). + Bold(true) + + lineNumStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")) + + previewStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("240")). + Padding(0, 1) + + filterStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("11")) + + // Build header (2 lines at top) + var headerLines []string + header := "r reload • q quit • j/k move • g/G first/last • ^d/u/f/b scroll • p preview • / filter" + headerLines = append(headerLines, headerStyle.Render(header)) + headerLines = append(headerLines, fmt.Sprintf("Command: %s", m.config.Command)) + + // Build prompt line (will go at bottom) + var promptLine string + if m.filterMode { + promptLine = filterStyle.Render(fmt.Sprintf("/%s█", m.filter)) + } else if m.filter != "" { + promptLine = promptStyle.Render(fmt.Sprintf("%s (filter: %s)", m.config.Prompt, m.filter)) + } else { + promptLine = promptStyle.Render(m.config.Prompt) + } + if m.loading { + promptLine += " [loading...]" + } + + // Calculate layout + listHeight := m.visibleLines() + listWidth := m.width + + if m.showPreview && (m.config.PreviewPosition == PreviewLeft || m.config.PreviewPosition == PreviewRight) { + listWidth = m.width / 2 + } + + // Build lines view + var listLines []string + for i := 0; i < listHeight; i++ { + lineIdx := m.offset + i + if lineIdx >= len(m.filtered) { + // Empty line to fill space + listLines = append(listLines, "") + continue + } + + idx := m.filtered[lineIdx] + if idx >= len(m.lines) { + listLines = append(listLines, "") + continue + } + line := m.lines[idx] + + lineText := line.Content + if m.config.ShowLineNums { + lineNum := lineNumStyle.Render(fmt.Sprintf("%*d ", m.config.LineNumWidth, line.Number)) + lineText = lineNum + line.Content + } + + // Truncate if too long + if len(lineText) > listWidth-2 && listWidth > 5 { + lineText = lineText[:listWidth-5] + "..." + } + + if lineIdx == m.cursor { + // Pad to full width for selection highlight + padding := listWidth - lipgloss.Width(lineText) + if padding > 0 { + lineText = lineText + strings.Repeat(" ", padding) + } + lineText = selectedStyle.Render(lineText) + } + + listLines = append(listLines, lineText) + } + + // Build preview content + var previewContent string + if m.showPreview && len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) { + idx := m.filtered[m.cursor] + if idx < len(m.lines) { + previewContent = m.lines[idx].Content + } + } + + // Compose final view + var contentSection string + + if !m.showPreview { + contentSection = strings.Join(listLines, "\n") + } else { + previewH := m.height * m.config.PreviewHeight / 100 + previewW := m.width - 4 + + if m.config.PreviewPosition == PreviewLeft || m.config.PreviewPosition == PreviewRight { + previewW = m.width/2 - 4 + } + + styledPreview := previewStyle. + Width(previewW). + Height(previewH - 2). + Render(previewContent) + + listContent := strings.Join(listLines, "\n") + + switch m.config.PreviewPosition { + case PreviewTop: + contentSection = styledPreview + "\n" + listContent + case PreviewBottom: + contentSection = listContent + "\n" + styledPreview + case PreviewLeft: + contentSection = lipgloss.JoinHorizontal(lipgloss.Top, styledPreview, " ", listContent) + case PreviewRight: + contentSection = lipgloss.JoinHorizontal(lipgloss.Top, listContent, " ", styledPreview) + } + } + + // Error message + if m.errorMsg != "" { + contentSection += "\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render("Error: "+m.errorMsg) + } + + // Combine: header at top, content in middle, prompt at bottom + fullView := strings.Join(headerLines, "\n") + "\n" + contentSection + "\n" + promptLine + + return fullView +} + +// Run starts the UI +func Run(cfg Config) error { + if cfg.PreviewPosition == "" { + cfg.PreviewPosition = PreviewBottom + } + + m := initialModel(cfg) + p := tea.NewProgram(m, tea.WithAltScreen()) + + _, err := p.Run() + return err +} diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go new file mode 100644 index 0000000..7718388 --- /dev/null +++ b/internal/ui/ui_test.go @@ -0,0 +1,260 @@ +package ui + +import ( + "testing" + + "github.com/chenasraf/watchr/internal/runner" +) + +func TestConfig(t *testing.T) { + cfg := Config{ + Command: "echo test", + Shell: "sh", + PreviewHeight: 40, + PreviewPosition: PreviewBottom, + ShowLineNums: true, + LineNumWidth: 6, + Prompt: "watchr> ", + RefreshSeconds: 5, + } + + if cfg.Command != "echo test" { + t.Errorf("expected command 'echo test', got %q", cfg.Command) + } + + if cfg.Shell != "sh" { + t.Errorf("expected shell 'sh', got %q", cfg.Shell) + } + + if cfg.PreviewHeight != 40 { + t.Errorf("expected preview height 40, got %d", cfg.PreviewHeight) + } + + if cfg.PreviewPosition != PreviewBottom { + t.Errorf("expected preview position 'bottom', got %q", cfg.PreviewPosition) + } + + if !cfg.ShowLineNums { + t.Error("expected ShowLineNums to be true") + } + + if cfg.LineNumWidth != 6 { + t.Errorf("expected line num width 6, got %d", cfg.LineNumWidth) + } + + if cfg.Prompt != "watchr> " { + t.Errorf("expected prompt 'watchr> ', got %q", cfg.Prompt) + } + + if cfg.RefreshSeconds != 5 { + t.Errorf("expected refresh seconds 5, got %d", cfg.RefreshSeconds) + } +} + +func TestPreviewPositionConstants(t *testing.T) { + tests := []struct { + pos PreviewPosition + want string + }{ + {PreviewBottom, "bottom"}, + {PreviewTop, "top"}, + {PreviewLeft, "left"}, + {PreviewRight, "right"}, + } + + for _, tt := range tests { + if string(tt.pos) != tt.want { + t.Errorf("PreviewPosition %v != %q", tt.pos, tt.want) + } + } +} + +func TestConfigDefaults(t *testing.T) { + // Test with zero values + cfg := Config{} + + if cfg.Command != "" { + t.Errorf("expected empty command, got %q", cfg.Command) + } + + if cfg.Shell != "" { + t.Errorf("expected empty shell, got %q", cfg.Shell) + } + + if cfg.PreviewHeight != 0 { + t.Errorf("expected preview height 0, got %d", cfg.PreviewHeight) + } + + if cfg.PreviewPosition != "" { + t.Errorf("expected empty preview position, got %q", cfg.PreviewPosition) + } + + if cfg.ShowLineNums { + t.Error("expected ShowLineNums to be false") + } + + if cfg.LineNumWidth != 0 { + t.Errorf("expected line num width 0, got %d", cfg.LineNumWidth) + } + + if cfg.Prompt != "" { + t.Errorf("expected empty prompt, got %q", cfg.Prompt) + } +} + +func TestInitialModel(t *testing.T) { + cfg := Config{ + Command: "echo test", + Shell: "sh", + PreviewHeight: 40, + PreviewPosition: PreviewBottom, + ShowLineNums: true, + LineNumWidth: 6, + Prompt: "watchr> ", + } + + m := initialModel(cfg) + + if m.config.Command != cfg.Command { + t.Errorf("expected command %q, got %q", cfg.Command, m.config.Command) + } + + if m.cursor != 0 { + t.Errorf("expected cursor at 0, got %d", m.cursor) + } + + if m.offset != 0 { + t.Errorf("expected offset at 0, got %d", m.offset) + } + + if m.filterMode { + t.Error("expected filterMode to be false") + } + + if m.showPreview { + t.Error("expected showPreview to be false") + } + + if !m.loading { + t.Error("expected loading to be true initially") + } +} + +func TestModelUpdateFiltered(t *testing.T) { + cfg := Config{ + Command: "echo test", + Shell: "sh", + } + + m := initialModel(cfg) + + // Add some test lines + m.lines = []runner.Line{ + {Number: 1, Content: "hello world"}, + {Number: 2, Content: "foo bar"}, + {Number: 3, Content: "hello foo"}, + {Number: 4, Content: "baz qux"}, + } + + // Test with no filter + m.filter = "" + m.updateFiltered() + + if len(m.filtered) != 4 { + t.Errorf("expected 4 filtered lines, got %d", len(m.filtered)) + } + + // Test with filter + m.filter = "hello" + m.updateFiltered() + + if len(m.filtered) != 2 { + t.Errorf("expected 2 filtered lines for 'hello', got %d", len(m.filtered)) + } + + // Test case insensitive + m.filter = "HELLO" + m.updateFiltered() + + if len(m.filtered) != 2 { + t.Errorf("expected 2 filtered lines for 'HELLO' (case insensitive), got %d", len(m.filtered)) + } + + // Test no matches + m.filter = "xyz" + m.updateFiltered() + + if len(m.filtered) != 0 { + t.Errorf("expected 0 filtered lines for 'xyz', got %d", len(m.filtered)) + } +} + +func TestModelMoveCursor(t *testing.T) { + cfg := Config{ + Command: "echo test", + Shell: "sh", + } + + m := initialModel(cfg) + m.filtered = []int{0, 1, 2, 3, 4} + m.height = 100 // enough height for all lines + + // Move down + m.moveCursor(1) + if m.cursor != 1 { + t.Errorf("expected cursor at 1, got %d", m.cursor) + } + + // Move down more + m.moveCursor(2) + if m.cursor != 3 { + t.Errorf("expected cursor at 3, got %d", m.cursor) + } + + // Move past end + m.moveCursor(10) + if m.cursor != 4 { + t.Errorf("expected cursor at 4 (clamped), got %d", m.cursor) + } + + // Move up + m.moveCursor(-2) + if m.cursor != 2 { + t.Errorf("expected cursor at 2, got %d", m.cursor) + } + + // Move past beginning + m.moveCursor(-10) + if m.cursor != 0 { + t.Errorf("expected cursor at 0 (clamped), got %d", m.cursor) + } +} + +func TestVisibleLines(t *testing.T) { + cfg := Config{ + Command: "echo test", + Shell: "sh", + PreviewHeight: 40, + PreviewPosition: PreviewBottom, + } + + m := initialModel(cfg) + m.height = 100 + + // Without preview + m.showPreview = false + visible := m.visibleLines() + expected := 100 - 3 // height - header + if visible != expected { + t.Errorf("expected %d visible lines without preview, got %d", expected, visible) + } + + // With preview at bottom + m.showPreview = true + visible = m.visibleLines() + previewHeight := 100 * 40 / 100 // 40% + expected = 100 - 3 - previewHeight + if visible != expected { + t.Errorf("expected %d visible lines with preview, got %d", expected, visible) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..4baf9f7 --- /dev/null +++ b/main.go @@ -0,0 +1,93 @@ +package main + +import ( + _ "embed" + "fmt" + "os" + "strings" + + "github.com/chenasraf/watchr/internal/ui" + flag "github.com/spf13/pflag" +) + +//go:embed version.txt +var version string + +func main() { + var ( + showVersion bool + showHelp bool + previewHeight int + previewPosition string + noLineNumbers bool + lineNumWidth int + prompt string + shell string + refreshSeconds int + ) + + flag.BoolVarP(&showVersion, "version", "v", false, "Show version") + flag.BoolVarP(&showHelp, "help", "h", false, "Show help") + flag.IntVarP(&previewHeight, "preview-height", "P", 40, "Preview window height/width percentage (1-100)") + flag.StringVar(&previewPosition, "preview-position", "bottom", "Preview position: bottom, top, left, right") + flag.BoolVarP(&noLineNumbers, "no-line-numbers", "n", false, "Disable line numbers") + flag.IntVarP(&lineNumWidth, "line-width", "w", 6, "Line number width") + flag.StringVarP(&prompt, "prompt", "p", "watchr> ", "Prompt string") + flag.StringVarP(&shell, "shell", "s", "sh", "Shell to use for executing commands") + flag.IntVarP(&refreshSeconds, "refresh", "r", 0, "Auto-refresh interval in seconds (0 = disabled)") + + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: watchr [options] \n\n") + fmt.Fprintf(os.Stderr, "A terminal UI for running and watching command output.\n\n") + fmt.Fprintf(os.Stderr, "Options:\n") + flag.PrintDefaults() + fmt.Fprintf(os.Stderr, "\nKeybindings:\n") + fmt.Fprintf(os.Stderr, " r, Ctrl-r Reload (re-run command)\n") + fmt.Fprintf(os.Stderr, " q, Esc Quit\n") + fmt.Fprintf(os.Stderr, " j, k Move down/up\n") + fmt.Fprintf(os.Stderr, " g Go to first line\n") + fmt.Fprintf(os.Stderr, " G Go to last line\n") + fmt.Fprintf(os.Stderr, " Ctrl-d/u Half page down/up\n") + fmt.Fprintf(os.Stderr, " PgDn/Up, ^f/b Full page down/up\n") + fmt.Fprintf(os.Stderr, " p Toggle preview\n") + fmt.Fprintf(os.Stderr, " / Enter filter mode\n") + fmt.Fprintf(os.Stderr, " Esc Exit filter mode / clear filter\n") + } + + flag.Parse() + + if showHelp { + flag.Usage() + os.Exit(0) + } + + if showVersion { + fmt.Printf("watchr %s\n", strings.TrimSpace(version)) + os.Exit(0) + } + + args := flag.Args() + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "Error: No command provided") + flag.Usage() + os.Exit(1) + } + + cmdStr := strings.Join(args, " ") + + config := ui.Config{ + Command: cmdStr, + Shell: shell, + PreviewHeight: previewHeight, + PreviewPosition: ui.PreviewPosition(previewPosition), + ShowLineNums: !noLineNumbers, + LineNumWidth: lineNumWidth, + Prompt: prompt, + RefreshSeconds: refreshSeconds, + } + + if err := ui.Run(config); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..77d6f4c --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +0.0.0