5 Commits

Author SHA1 Message Date
github-actions[bot]
a3c46bfa7c chore(master): release 1.3.0 2025-12-04 13:45:48 +02:00
5e1b807105 fix: current line style 2025-12-04 13:22:50 +02:00
51b9e92ddd feat: add exit status to command header 2025-12-04 13:05:26 +02:00
320874a61a build: add lint makefile target 2025-12-04 13:05:14 +02:00
cdd895d19a feat: help overlay window 2025-12-04 12:57:30 +02:00
10 changed files with 402 additions and 51 deletions

4
.editorconfig Normal file
View File

@@ -0,0 +1,4 @@
[Makefile]
indent_style = tab
indent_size = 4
tab_width = 4

View File

@@ -1,5 +1,18 @@
# Changelog
## [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)

View File

@@ -22,6 +22,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..."

View File

@@ -177,6 +177,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 |
---

View File

@@ -38,22 +38,28 @@ func NewRunner(shell, command string) *Runner {
}
}
// Run executes the command and returns output lines
func (r *Runner) Run(ctx context.Context) ([]Line, error) {
// 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) {
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)
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,10 +85,15 @@ 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()
// 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()
}
}
return lines, nil
return Result{Lines: lines, ExitCode: exitCode}, nil
}
// RunStreaming executes the command and streams output lines to the callback

View File

@@ -52,17 +52,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 +103,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 +121,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)
}
}

View File

@@ -46,6 +46,7 @@ type model struct {
filter string
filterMode bool
showPreview bool
showHelp bool // help overlay visible
width int
height int
runner *runner.Runner
@@ -54,10 +55,14 @@ type model struct {
loading bool
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{}
@@ -114,11 +119,11 @@ func (m model) runCommand() tea.Cmd {
r := m.runner
ctx := m.ctx
return func() tea.Msg {
lines, err := r.Run(ctx)
result, err := r.Run(ctx)
if err != nil {
return errMsg{err}
}
return linesMsg(lines)
return resultMsg{lines: result.Lines, exitCode: result.ExitCode}
}
}
@@ -132,8 +137,9 @@ 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 resultMsg:
m.lines = msg.lines
m.exitCode = msg.exitCode
m.loading = false
m.updateFiltered()
return m, nil
@@ -167,6 +173,15 @@ func (m model) tickCmd() tea.Cmd {
}
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 {
@@ -228,6 +243,8 @@ func (m *model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
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) {
@@ -298,8 +315,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
@@ -392,11 +409,261 @@ func (m *model) updateFiltered() {
m.offset = 0
}
// 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 "Loading..."
}
// 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 +682,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,9 +735,23 @@ 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
if m.loading {
// Still loading - no status yet
commandLine = prefix + m.config.Command
} else if m.exitCode == 0 {
// Success - green checkmark and green command
successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // green
commandLine = prefix + successStyle.Render("✓ "+m.config.Command)
} else {
// 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
@@ -493,6 +770,15 @@ func (m model) View() string {
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
@@ -522,6 +808,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 +822,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 = lineText + strings.Repeat(" ", padding)
}
lineText = selectedStyle.Render(lineText)
}
lineText = selectedStyle.Render(lineText)
}
listLines = append(listLines, lineText)
@@ -583,8 +893,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)

View File

@@ -252,8 +252,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

View File

@@ -55,6 +55,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() {

View File

@@ -1 +1 @@
1.2.0
1.3.0