mirror of
https://github.com/chenasraf/watchr.git
synced 2026-05-17 17:28:06 +00:00
1419 lines
38 KiB
Go
Executable File
1419 lines
38 KiB
Go
Executable File
package ui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os/exec"
|
|
"regexp"
|
|
"runtime"
|
|
"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
|
|
PreviewSize int
|
|
PreviewSizeIsPercent bool
|
|
PreviewPosition PreviewPosition
|
|
ShowLineNums bool
|
|
LineNumWidth int
|
|
Prompt string
|
|
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
|
|
filterCursor int // cursor position within filter string
|
|
filterMode bool
|
|
filterRegex bool // true when filter is in regex mode
|
|
filterRegexErr error // non-nil when regex pattern is invalid
|
|
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
|
|
type resultMsg struct {
|
|
lines []runner.Line
|
|
exitCode int
|
|
}
|
|
type errMsg struct{ err error }
|
|
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 countdownTickMsg struct { // periodic update for refresh countdown display
|
|
generation int
|
|
}
|
|
|
|
// Spinner frames for the loading animation
|
|
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
|
|
|
func (e errMsg) Error() string { return e.err.Error() }
|
|
|
|
// copyToClipboard copies text to the system clipboard using OS-specific commands
|
|
func copyToClipboard(text string) error {
|
|
var cmd *exec.Cmd
|
|
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
cmd = exec.Command("pbcopy")
|
|
case "linux":
|
|
// Try xclip first, fall back to xsel
|
|
if _, err := exec.LookPath("xclip"); err == nil {
|
|
cmd = exec.Command("xclip", "-selection", "clipboard")
|
|
} else {
|
|
cmd = exec.Command("xsel", "--clipboard", "--input")
|
|
}
|
|
case "windows":
|
|
cmd = exec.Command("clip")
|
|
default:
|
|
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
|
|
}
|
|
|
|
cmd.Stdin = strings.NewReader(text)
|
|
return cmd.Run()
|
|
}
|
|
|
|
func initialModel(cfg Config) model {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
var r *runner.Runner
|
|
if cfg.Interactive {
|
|
r = runner.NewInteractiveRunner(cfg.Shell, cfg.Command)
|
|
} else {
|
|
r = runner.NewRunner(cfg.Shell, cfg.Command)
|
|
}
|
|
|
|
return model{
|
|
config: cfg,
|
|
lines: []runner.Line{},
|
|
filtered: []int{},
|
|
cursor: 0,
|
|
offset: 0,
|
|
filter: "",
|
|
filterMode: false,
|
|
showPreview: false,
|
|
runner: r,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
loading: true,
|
|
}
|
|
}
|
|
|
|
func (m *model) Init() tea.Cmd {
|
|
// Send a message to start streaming (handled in Update with pointer receiver)
|
|
return func() tea.Msg {
|
|
return startStreamMsg{}
|
|
}
|
|
}
|
|
|
|
func (m model) spinnerTickCmd() tea.Cmd {
|
|
return tea.Tick(80*time.Millisecond, func(t time.Time) tea.Msg {
|
|
return spinnerTickMsg(t)
|
|
})
|
|
}
|
|
|
|
func (m model) streamTickCmd() tea.Cmd {
|
|
return tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg {
|
|
return streamTickMsg(t)
|
|
})
|
|
}
|
|
|
|
func (m model) 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 {
|
|
m.cancel()
|
|
}
|
|
m.ctx, m.cancel = context.WithCancel(context.Background())
|
|
|
|
// Pass previous lines for in-place updates
|
|
m.streamResult = m.runner.RunStreaming(m.ctx, m.lines)
|
|
m.streaming = true
|
|
m.loading = true
|
|
m.lastLineCount = len(m.lines)
|
|
m.exitCode = -1
|
|
m.errorMsg = ""
|
|
m.userScrolled = false
|
|
|
|
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) {
|
|
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 startStreamMsg:
|
|
cmd := m.startStreaming()
|
|
return m, tea.Batch(cmd, m.spinnerTickCmd())
|
|
|
|
case resultMsg:
|
|
m.lines = msg.lines
|
|
m.exitCode = msg.exitCode
|
|
m.loading = false
|
|
m.streaming = false
|
|
m.updateFiltered()
|
|
return m, nil
|
|
|
|
case streamTickMsg:
|
|
if m.streamResult == nil {
|
|
return m, nil
|
|
}
|
|
|
|
// Check for new lines
|
|
newLines := m.streamResult.GetLines()
|
|
newCount := len(newLines)
|
|
|
|
if newCount != m.lastLineCount {
|
|
m.lines = newLines
|
|
m.lastLineCount = newCount
|
|
m.updateFiltered()
|
|
|
|
// 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
|
|
if m.streamResult.IsDone() {
|
|
m.streaming = false
|
|
m.loading = false
|
|
m.exitCode = m.streamResult.ExitCode
|
|
if m.streamResult.Error != nil {
|
|
m.errorMsg = m.streamResult.Error.Error()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Continue streaming
|
|
return m, m.streamTickCmd()
|
|
|
|
case tickMsg:
|
|
// 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())
|
|
}
|
|
return m, nil
|
|
|
|
case errMsg:
|
|
m.errorMsg = msg.Error()
|
|
m.loading = false
|
|
m.streaming = false
|
|
return m, nil
|
|
|
|
case clearStatusMsg:
|
|
m.statusMsg = ""
|
|
return m, nil
|
|
|
|
case spinnerTickMsg:
|
|
if m.loading || m.streaming {
|
|
m.spinnerFrame = (m.spinnerFrame + 1) % len(spinnerFrames)
|
|
return m, m.spinnerTickCmd()
|
|
}
|
|
return m, nil
|
|
|
|
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 {
|
|
gen := m.refreshGeneration
|
|
return tea.Tick(m.config.RefreshInterval, func(t time.Time) tea.Msg {
|
|
return tickMsg{generation: gen}
|
|
})
|
|
}
|
|
|
|
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 {
|
|
case tea.KeyEsc:
|
|
m.filterMode = false
|
|
m.filter = ""
|
|
m.filterCursor = 0
|
|
m.filterRegex = false
|
|
m.filterRegexErr = nil
|
|
m.updateFiltered()
|
|
return m, nil
|
|
case tea.KeyEnter:
|
|
m.filterMode = false
|
|
return m, nil
|
|
case tea.KeyLeft:
|
|
if msg.Alt {
|
|
// Alt+Left: move to previous word boundary
|
|
pos := m.filterCursor
|
|
for pos > 0 && m.filter[pos-1] == ' ' {
|
|
pos--
|
|
}
|
|
for pos > 0 && m.filter[pos-1] != ' ' {
|
|
pos--
|
|
}
|
|
m.filterCursor = pos
|
|
} else if m.filterCursor > 0 {
|
|
m.filterCursor--
|
|
}
|
|
return m, nil
|
|
case tea.KeyRight:
|
|
if msg.Alt {
|
|
// Alt+Right: move to next word boundary
|
|
pos := m.filterCursor
|
|
for pos < len(m.filter) && m.filter[pos] != ' ' {
|
|
pos++
|
|
}
|
|
for pos < len(m.filter) && m.filter[pos] == ' ' {
|
|
pos++
|
|
}
|
|
m.filterCursor = pos
|
|
} else if m.filterCursor < len(m.filter) {
|
|
m.filterCursor++
|
|
}
|
|
return m, nil
|
|
case tea.KeyBackspace:
|
|
if msg.Alt {
|
|
// Alt+Backspace: delete word behind cursor
|
|
if m.filterCursor > 0 {
|
|
pos := m.filterCursor
|
|
// Skip trailing spaces
|
|
for pos > 0 && m.filter[pos-1] == ' ' {
|
|
pos--
|
|
}
|
|
// Skip word characters
|
|
for pos > 0 && m.filter[pos-1] != ' ' {
|
|
pos--
|
|
}
|
|
m.filter = m.filter[:pos] + m.filter[m.filterCursor:]
|
|
m.filterCursor = pos
|
|
m.updateFiltered()
|
|
}
|
|
} else if m.filterCursor > 0 {
|
|
m.filter = m.filter[:m.filterCursor-1] + m.filter[m.filterCursor:]
|
|
m.filterCursor--
|
|
m.updateFiltered()
|
|
}
|
|
return m, nil
|
|
default:
|
|
if len(msg.Runes) > 0 {
|
|
s := string(msg.Runes)
|
|
if s == "/" && m.filter == "" {
|
|
m.filterRegex = !m.filterRegex
|
|
m.filterRegexErr = nil
|
|
m.updateFiltered()
|
|
} else {
|
|
m.filter = m.filter[:m.filterCursor] + s + m.filter[m.filterCursor:]
|
|
m.filterCursor += len(s)
|
|
m.updateFiltered()
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
// Normal mode keybindings
|
|
switch msg.String() {
|
|
case "q", "ctrl+c":
|
|
m.cancel()
|
|
return m, tea.Quit
|
|
case "esc":
|
|
// Clear filter if active, otherwise quit
|
|
if m.filter != "" || m.filterRegex {
|
|
m.filter = ""
|
|
m.filterCursor = 0
|
|
m.filterRegex = false
|
|
m.filterRegexErr = nil
|
|
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 and reset auto-refresh timer
|
|
m.refreshGeneration++
|
|
cmd := m.startStreaming()
|
|
return m, tea.Batch(cmd, m.spinnerTickCmd())
|
|
case "c":
|
|
// Stop the running command if one is running
|
|
if m.streaming {
|
|
m.cancel()
|
|
m.statusMsg = "Command stopped"
|
|
return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
|
|
return clearStatusMsg{}
|
|
})
|
|
}
|
|
case "/":
|
|
m.filterMode = true
|
|
m.filterCursor = len(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) {
|
|
idx := m.filtered[m.cursor]
|
|
if idx < len(m.lines) {
|
|
content := m.lines[idx].Content
|
|
if err := copyToClipboard(content); err != nil {
|
|
m.statusMsg = "Failed to copy"
|
|
} else {
|
|
m.statusMsg = "Copied to clipboard"
|
|
}
|
|
return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
|
|
return clearStatusMsg{}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
idealOffset = max(idealOffset, 0)
|
|
maxOffset := max(len(m.filtered)-visible, 0)
|
|
idealOffset = min(idealOffset, maxOffset)
|
|
|
|
m.offset = idealOffset
|
|
}
|
|
|
|
func (m model) previewSize() int {
|
|
if m.config.PreviewSizeIsPercent {
|
|
if m.config.PreviewPosition == PreviewLeft || m.config.PreviewPosition == PreviewRight {
|
|
return m.width * m.config.PreviewSize / 100
|
|
}
|
|
return m.height * m.config.PreviewSize / 100
|
|
}
|
|
return m.config.PreviewSize
|
|
}
|
|
|
|
func (m model) visibleLines() int {
|
|
// 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
|
|
}
|
|
return m.height - fixedLines
|
|
}
|
|
|
|
const ellipsis = "…"
|
|
|
|
// truncateToWidth truncates a string to fit within the given visual width,
|
|
// adding an ellipsis if truncation occurs. Uses visual width, not byte count.
|
|
func truncateToWidth(s string, maxWidth int) string {
|
|
if maxWidth <= 0 {
|
|
return ""
|
|
}
|
|
sw := lipgloss.Width(s)
|
|
if sw <= maxWidth {
|
|
return s
|
|
}
|
|
// Need to truncate - leave room for ellipsis (1 char wide)
|
|
targetWidth := maxWidth - 1
|
|
if targetWidth <= 0 {
|
|
return ellipsis
|
|
}
|
|
|
|
// Truncate rune by rune until we fit
|
|
var result strings.Builder
|
|
currentWidth := 0
|
|
for _, r := range s {
|
|
runeWidth := lipgloss.Width(string(r))
|
|
if currentWidth+runeWidth > targetWidth {
|
|
break
|
|
}
|
|
result.WriteRune(r)
|
|
currentWidth += runeWidth
|
|
}
|
|
return result.String() + ellipsis
|
|
}
|
|
|
|
// wrapText wraps text to fit within the given width, returning multiple lines.
|
|
// It is ANSI-aware: escape sequences are preserved intact and don't count
|
|
// toward the visible width. When a line wraps, any active ANSI state is
|
|
// carried over so colours continue on the next line.
|
|
func wrapText(s string, width int) []string {
|
|
if width <= 0 {
|
|
return nil
|
|
}
|
|
if s == "" {
|
|
return []string{""}
|
|
}
|
|
|
|
var lines []string
|
|
var currentLine strings.Builder
|
|
currentWidth := 0
|
|
// Track the last seen ANSI escape so we can re-apply it after a wrap
|
|
var activeANSI string
|
|
|
|
i := 0
|
|
runes := []rune(s)
|
|
for i < len(runes) {
|
|
// Check for ANSI escape sequence: ESC [ ... final_byte
|
|
if runes[i] == '\033' && i+1 < len(runes) && runes[i+1] == '[' {
|
|
// Consume entire escape sequence
|
|
var seq strings.Builder
|
|
seq.WriteRune(runes[i]) // ESC
|
|
i++
|
|
seq.WriteRune(runes[i]) // [
|
|
i++
|
|
for i < len(runes) {
|
|
seq.WriteRune(runes[i])
|
|
// Final byte of CSI sequence is in range 0x40-0x7E
|
|
if runes[i] >= 0x40 && runes[i] <= 0x7E {
|
|
i++
|
|
break
|
|
}
|
|
i++
|
|
}
|
|
seqStr := seq.String()
|
|
currentLine.WriteString(seqStr)
|
|
// Track reset vs color sequences
|
|
if seqStr == "\033[0m" || seqStr == "\033[m" {
|
|
activeANSI = ""
|
|
} else {
|
|
activeANSI = seqStr
|
|
}
|
|
continue
|
|
}
|
|
|
|
r := runes[i]
|
|
runeWidth := lipgloss.Width(string(r))
|
|
if currentWidth+runeWidth > width {
|
|
// Close any active ANSI on this line before wrapping
|
|
if activeANSI != "" {
|
|
currentLine.WriteString("\033[0m")
|
|
}
|
|
lines = append(lines, currentLine.String())
|
|
currentLine.Reset()
|
|
currentWidth = 0
|
|
// Re-apply active ANSI on the new line
|
|
if activeANSI != "" {
|
|
currentLine.WriteString(activeANSI)
|
|
}
|
|
}
|
|
currentLine.WriteRune(r)
|
|
currentWidth += runeWidth
|
|
i++
|
|
}
|
|
// Don't forget the last line
|
|
if currentLine.Len() > 0 {
|
|
lines = append(lines, currentLine.String())
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
// wrapPreviewContent splits multi-line content (e.g. pretty-printed JSON) by
|
|
// newlines first, then wraps each line to fit within the given width.
|
|
func wrapPreviewContent(s string, width int) []string {
|
|
var result []string
|
|
for line := range strings.SplitSeq(s, "\n") {
|
|
if line == "" {
|
|
result = append(result, "")
|
|
continue
|
|
}
|
|
wrapped := wrapText(line, width)
|
|
result = append(result, wrapped...)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (m *model) updateFiltered() {
|
|
m.filtered = []int{}
|
|
m.filterRegexErr = nil
|
|
|
|
if m.filterRegex && m.filter != "" {
|
|
re, err := regexp.Compile("(?i)" + m.filter)
|
|
if err != nil {
|
|
m.filterRegexErr = err
|
|
// Show all lines when regex is invalid
|
|
for i := range m.lines {
|
|
m.filtered = append(m.filtered, i)
|
|
}
|
|
} else {
|
|
for i, line := range m.lines {
|
|
if re.MatchString(line.Content) {
|
|
m.filtered = append(m.filtered, i)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
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"},
|
|
{"//", "Toggle regex filter mode"},
|
|
{"Esc", "Exit filter / clear"},
|
|
{"", ""},
|
|
{"r / Ctrl+r", "Reload command"},
|
|
{"c", "Stop running command"},
|
|
{"y", "Copy line to clipboard"},
|
|
{"q / Esc", "Quit"},
|
|
{"?", "Toggle this help"},
|
|
}
|
|
|
|
// Build content
|
|
var content strings.Builder
|
|
content.WriteString(titleStyle.Render("Keybindings"))
|
|
content.WriteString("\n\n")
|
|
|
|
for _, b := range bindings {
|
|
if b.key == "" {
|
|
content.WriteString("\n")
|
|
continue
|
|
}
|
|
key := keyStyle.Render(fmt.Sprintf("%-18s", b.key))
|
|
desc := descStyle.Render(b.desc)
|
|
content.WriteString(fmt.Sprintf(" %s %s\n", key, desc))
|
|
}
|
|
|
|
content.WriteString("\n")
|
|
content.WriteString(descStyle.Render("Press any key to close"))
|
|
|
|
// Create box style
|
|
boxStyle := lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(lipgloss.Color("12")).
|
|
Padding(1, 2)
|
|
|
|
box = boxStyle.Render(content.String())
|
|
boxWidth = lipgloss.Width(box)
|
|
boxHeight = lipgloss.Height(box)
|
|
|
|
return box, boxWidth, boxHeight
|
|
}
|
|
|
|
// splitAtVisualWidth splits a string at a visual width position, handling ANSI codes
|
|
// Returns (left part, right part) where left has exactly targetWidth visual width
|
|
func splitAtVisualWidth(s string, targetWidth int) (string, string) {
|
|
var left, right strings.Builder
|
|
visualWidth := 0
|
|
inEscape := false
|
|
runes := []rune(s)
|
|
|
|
i := 0
|
|
// Build left part up to targetWidth
|
|
for i < len(runes) && visualWidth < targetWidth {
|
|
r := runes[i]
|
|
|
|
if r == '\x1b' {
|
|
// Start of ANSI escape sequence - include it in left part
|
|
left.WriteRune(r)
|
|
i++
|
|
for i < len(runes) && !isAnsiTerminator(runes[i]) {
|
|
left.WriteRune(runes[i])
|
|
i++
|
|
}
|
|
if i < len(runes) {
|
|
left.WriteRune(runes[i]) // terminator
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
|
|
runeWidth := lipgloss.Width(string(r))
|
|
if visualWidth+runeWidth <= targetWidth {
|
|
left.WriteRune(r)
|
|
visualWidth += runeWidth
|
|
i++
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Pad left if needed
|
|
for visualWidth < targetWidth {
|
|
left.WriteRune(' ')
|
|
visualWidth++
|
|
}
|
|
|
|
// Skip runes in the "overlay zone" - we don't need them for right part calculation
|
|
// The caller will handle inserting the overlay content
|
|
|
|
// Build right part from remaining
|
|
for ; i < len(runes); i++ {
|
|
r := runes[i]
|
|
if r == '\x1b' {
|
|
right.WriteRune(r)
|
|
i++
|
|
for i < len(runes) && !isAnsiTerminator(runes[i]) {
|
|
right.WriteRune(runes[i])
|
|
i++
|
|
}
|
|
if i < len(runes) {
|
|
right.WriteRune(runes[i])
|
|
}
|
|
continue
|
|
}
|
|
right.WriteRune(r)
|
|
}
|
|
|
|
_ = inEscape // unused but kept for clarity
|
|
return left.String(), right.String()
|
|
}
|
|
|
|
// skipVisualWidth skips a number of visual width units in a string, handling ANSI codes
|
|
// It preserves and returns ANSI sequences encountered during skipping so styling can be restored
|
|
func skipVisualWidth(s string, skipWidth int) string {
|
|
var result strings.Builder
|
|
var ansiState strings.Builder // collect ANSI codes while skipping
|
|
visualWidth := 0
|
|
runes := []rune(s)
|
|
|
|
i := 0
|
|
// Skip until we've passed skipWidth, but collect ANSI codes
|
|
for i < len(runes) && visualWidth < skipWidth {
|
|
r := runes[i]
|
|
|
|
if r == '\x1b' {
|
|
// ANSI escape - collect it (don't count visual width)
|
|
ansiState.WriteRune(r)
|
|
i++
|
|
for i < len(runes) && !isAnsiTerminator(runes[i]) {
|
|
ansiState.WriteRune(runes[i])
|
|
i++
|
|
}
|
|
if i < len(runes) {
|
|
ansiState.WriteRune(runes[i]) // terminator
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
|
|
runeWidth := lipgloss.Width(string(r))
|
|
visualWidth += runeWidth
|
|
i++
|
|
}
|
|
|
|
// Prepend collected ANSI state to restore styling
|
|
result.WriteString(ansiState.String())
|
|
|
|
// Output the rest
|
|
for ; i < len(runes); i++ {
|
|
result.WriteRune(runes[i])
|
|
}
|
|
|
|
return result.String()
|
|
}
|
|
|
|
func isAnsiTerminator(r rune) bool {
|
|
return (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')
|
|
}
|
|
|
|
// overlayBox composites an overlay box on top of a base view
|
|
func overlayBox(base string, box string, boxWidth, boxHeight, screenWidth, screenHeight int) string {
|
|
// ANSI reset sequence to stop any styling from bleeding into overlay
|
|
const ansiReset = "\x1b[0m"
|
|
|
|
// Split base into lines
|
|
baseLines := strings.Split(base, "\n")
|
|
|
|
// Ensure we have enough lines
|
|
for len(baseLines) < screenHeight {
|
|
baseLines = append(baseLines, "")
|
|
}
|
|
|
|
// Split box into lines
|
|
boxLines := strings.Split(box, "\n")
|
|
|
|
// Calculate center position
|
|
startX := (screenWidth - boxWidth) / 2
|
|
startY := (screenHeight - boxHeight) / 2
|
|
|
|
if startX < 0 {
|
|
startX = 0
|
|
}
|
|
if startY < 0 {
|
|
startY = 0
|
|
}
|
|
|
|
// Overlay box onto base
|
|
for i, boxLine := range boxLines {
|
|
y := startY + i
|
|
if y >= len(baseLines) {
|
|
break
|
|
}
|
|
|
|
baseLine := baseLines[y]
|
|
baseVisualWidth := lipgloss.Width(baseLine)
|
|
|
|
// Get left part (before overlay)
|
|
leftPart, _ := splitAtVisualWidth(baseLine, startX)
|
|
|
|
// Get right part (after overlay)
|
|
endX := startX + boxWidth
|
|
var rightPart string
|
|
if endX < baseVisualWidth {
|
|
rightPart = skipVisualWidth(baseLine, endX)
|
|
}
|
|
|
|
// Combine: left + reset + box + right
|
|
// Reset before overlay to stop highlight bleeding into overlay
|
|
baseLines[y] = leftPart + ansiReset + boxLine + rightPart
|
|
}
|
|
|
|
return strings.Join(baseLines, "\n")
|
|
}
|
|
|
|
func (m *model) View() string {
|
|
if m.width == 0 || m.height == 0 {
|
|
return spinnerFrames[m.spinnerFrame] + " Running command…"
|
|
}
|
|
|
|
// Render the main UI
|
|
mainView := m.renderMainView()
|
|
|
|
// Overlay help if active
|
|
if m.showHelp {
|
|
box, boxWidth, boxHeight := m.renderHelpOverlay()
|
|
return overlayBox(mainView, box, boxWidth, boxHeight, m.width, m.height)
|
|
}
|
|
|
|
return mainView
|
|
}
|
|
|
|
func (m model) renderMainView() string {
|
|
// Box drawing characters (rounded)
|
|
const (
|
|
topLeft = "╭"
|
|
topRight = "╮"
|
|
bottomLeft = "╰"
|
|
bottomRight = "╯"
|
|
horizontal = "─"
|
|
vertical = "│"
|
|
leftT = "├"
|
|
rightT = "┤"
|
|
topT = "┬"
|
|
bottomT = "┴"
|
|
)
|
|
|
|
borderColor := lipgloss.Color("240")
|
|
borderStyle := lipgloss.NewStyle().Foreground(borderColor)
|
|
|
|
// Styles
|
|
promptStyle := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("14"))
|
|
|
|
selectedStyle := lipgloss.NewStyle().
|
|
Background(lipgloss.Color("15")).
|
|
Foreground(lipgloss.Color("#000000")).
|
|
Bold(true)
|
|
|
|
lineNumStyle := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("241"))
|
|
|
|
filterStyle := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("11"))
|
|
|
|
filterRegexStyle := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("13"))
|
|
|
|
filterErrStyle := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("9"))
|
|
|
|
// Inner width (excluding border characters)
|
|
innerWidth := m.width - 2
|
|
|
|
// Helper to create a horizontal line (optionally with a T-junction for vertical split)
|
|
hLine := func(left, right string, splitPos int) string {
|
|
if splitPos > 0 && splitPos < innerWidth {
|
|
return borderStyle.Render(left + strings.Repeat(horizontal, splitPos) + topT + strings.Repeat(horizontal, innerWidth-splitPos-1) + right)
|
|
}
|
|
return borderStyle.Render(left + strings.Repeat(horizontal, innerWidth) + right)
|
|
}
|
|
|
|
// Helper for header separator line with T junction pointing down (for vertical split below)
|
|
hLineMid := func(left, right string, splitPos int) string {
|
|
if splitPos > 0 && splitPos < innerWidth {
|
|
return borderStyle.Render(left + strings.Repeat(horizontal, splitPos) + topT + strings.Repeat(horizontal, innerWidth-splitPos-1) + right)
|
|
}
|
|
return borderStyle.Render(left + strings.Repeat(horizontal, innerWidth) + right)
|
|
}
|
|
|
|
// Helper for bottom line with vertical split
|
|
hLineBottom := func(left, right string, splitPos int) string {
|
|
if splitPos > 0 && splitPos < innerWidth {
|
|
return borderStyle.Render(left + strings.Repeat(horizontal, splitPos) + bottomT + strings.Repeat(horizontal, innerWidth-splitPos-1) + right)
|
|
}
|
|
return borderStyle.Render(left + strings.Repeat(horizontal, innerWidth) + right)
|
|
}
|
|
|
|
// Helper to pad content to inner width
|
|
padLine := func(content string) string {
|
|
contentWidth := lipgloss.Width(content)
|
|
if contentWidth < innerWidth {
|
|
content += strings.Repeat(" ", innerWidth-contentWidth)
|
|
} else if contentWidth > innerWidth {
|
|
// Use lipgloss style with MaxWidth for ANSI-safe truncation
|
|
content = lipgloss.NewStyle().MaxWidth(innerWidth-1).Render(content) + ellipsis
|
|
}
|
|
return borderStyle.Render(vertical) + content + borderStyle.Render(vertical)
|
|
}
|
|
|
|
// Build header content with status indicator
|
|
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true) // blue
|
|
prefix := titleStyle.Render("watchr") + " • "
|
|
|
|
var commandLine string
|
|
switch {
|
|
case m.streaming:
|
|
// Streaming - show streaming indicator
|
|
streamStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // cyan
|
|
commandLine = prefix + streamStyle.Render("◉ "+m.config.Command)
|
|
case m.loading:
|
|
// Still loading - no status yet
|
|
commandLine = prefix + m.config.Command
|
|
case m.exitCode == 0:
|
|
// Success - green checkmark and green command
|
|
successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // green
|
|
commandLine = prefix + successStyle.Render("✓ "+m.config.Command)
|
|
default:
|
|
// Failure - red cross with exit code and red command
|
|
failStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) // red
|
|
commandLine = prefix + failStyle.Render(fmt.Sprintf("✗ [%d] %s", m.exitCode, m.config.Command))
|
|
}
|
|
|
|
// 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 {
|
|
case m.filterMode && m.filterRegex:
|
|
label := filterRegexStyle.Render("regex/")
|
|
before := m.filter[:m.filterCursor]
|
|
after := m.filter[m.filterCursor:]
|
|
input := filterStyle.Render(before) + "█" + filterStyle.Render(after)
|
|
promptLine = label + input
|
|
if m.filterRegexErr != nil {
|
|
promptLine += " " + filterErrStyle.Render("(invalid regex)")
|
|
}
|
|
case m.filterMode:
|
|
before := m.filter[:m.filterCursor]
|
|
after := m.filter[m.filterCursor:]
|
|
promptLine = filterStyle.Render("/"+before) + "█" + filterStyle.Render(after)
|
|
case m.filter != "" && m.filterRegex:
|
|
promptLine = promptStyle.Render(fmt.Sprintf("%s (regex: %s)", m.config.Prompt, m.filter))
|
|
case m.filter != "":
|
|
promptLine = promptStyle.Render(fmt.Sprintf("%s (filter: %s)", m.config.Prompt, m.filter))
|
|
default:
|
|
promptLine = promptStyle.Render(m.config.Prompt)
|
|
}
|
|
if m.streaming {
|
|
promptLine += " " + spinnerFrames[m.spinnerFrame] + " Streaming…"
|
|
} else if m.loading {
|
|
promptLine += " " + spinnerFrames[m.spinnerFrame] + " Running command…"
|
|
}
|
|
if m.statusMsg != "" {
|
|
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // green
|
|
promptLine += " " + statusStyle.Render(m.statusMsg)
|
|
}
|
|
|
|
// Add help hint on the right
|
|
helpHint := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render("? for help")
|
|
promptWidth := lipgloss.Width(promptLine)
|
|
hintWidth := lipgloss.Width(helpHint)
|
|
gap := m.width - promptWidth - hintWidth
|
|
if gap > 0 {
|
|
promptLine += strings.Repeat(" ", gap) + helpHint
|
|
}
|
|
|
|
// Calculate layout
|
|
listHeight := m.visibleLines()
|
|
// listWidth is content area minus 1 for padding before border
|
|
listWidth := innerWidth - 1
|
|
|
|
if m.showPreview && (m.config.PreviewPosition == PreviewLeft || m.config.PreviewPosition == PreviewRight) {
|
|
// For horizontal split: innerWidth = leftW + 1 (middle border) + rightW
|
|
// List gets the non-preview side width, minus 1 for padding
|
|
listWidth = innerWidth - m.previewSize() - 2
|
|
}
|
|
|
|
// Build lines view
|
|
var listLines []string
|
|
for i := range listHeight {
|
|
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]
|
|
|
|
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)
|
|
lineNumWidth := len(lineNumStr) // Plain ASCII, so len() == visual width
|
|
contentWidth := listWidth - lineNumWidth
|
|
|
|
// Truncate content (no ANSI codes yet)
|
|
content := truncateToWidth(line.Content, contentWidth)
|
|
|
|
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 isSelected {
|
|
// Pad to full width for selection highlight
|
|
padding := fullWidth - lipgloss.Width(lineText)
|
|
if padding > 0 {
|
|
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 = highlightJSON(m.lines[idx].Content)
|
|
}
|
|
}
|
|
|
|
// Error message
|
|
if m.errorMsg != "" {
|
|
listLines = append(listLines, lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render("Error: "+m.errorMsg))
|
|
}
|
|
|
|
// Calculate vertical split position for left/right preview
|
|
// This must match where the middle vertical bar falls in content lines
|
|
var vSplitPos int
|
|
if m.showPreview {
|
|
switch m.config.PreviewPosition {
|
|
case PreviewLeft:
|
|
vSplitPos = m.previewSize()
|
|
case PreviewRight:
|
|
// leftW = innerWidth - previewSize - 1, so split is at leftW
|
|
vSplitPos = innerWidth - m.previewSize() - 1
|
|
}
|
|
}
|
|
|
|
// Build the unified box
|
|
var lines []string
|
|
|
|
// Top border (no junction - vertical split starts at header separator)
|
|
lines = append(lines, hLine(topLeft, topRight, 0))
|
|
|
|
// Header line (command only)
|
|
lines = append(lines, padLine(commandLine))
|
|
|
|
// Separator between header and content (T junction if vertical split)
|
|
lines = append(lines, hLineMid(leftT, rightT, vSplitPos))
|
|
|
|
// Content area (with optional preview)
|
|
if !m.showPreview {
|
|
// Just content lines, padded to fill height
|
|
for i := range listHeight {
|
|
if i < len(listLines) {
|
|
lines = append(lines, padLine(listLines[i]))
|
|
} else {
|
|
lines = append(lines, padLine(""))
|
|
}
|
|
}
|
|
} else {
|
|
previewH := m.previewSize()
|
|
|
|
switch m.config.PreviewPosition {
|
|
case PreviewTop, PreviewBottom:
|
|
// Vertical split - preview above or below content
|
|
var previewLines []string
|
|
// Wrap preview content to fit width
|
|
if previewContent != "" {
|
|
previewLines = wrapPreviewContent(previewContent, innerWidth)
|
|
}
|
|
// Pad preview to height
|
|
for len(previewLines) < previewH {
|
|
previewLines = append(previewLines, "")
|
|
}
|
|
|
|
if m.config.PreviewPosition == PreviewTop {
|
|
// Preview first
|
|
for _, line := range previewLines[:previewH] {
|
|
lines = append(lines, padLine(line))
|
|
}
|
|
// Separator (no vertical split for top/bottom preview)
|
|
lines = append(lines, hLine(leftT, rightT, 0))
|
|
// Then content, padded to fill height
|
|
for i := range listHeight {
|
|
if i < len(listLines) {
|
|
lines = append(lines, padLine(listLines[i]))
|
|
} else {
|
|
lines = append(lines, padLine(""))
|
|
}
|
|
}
|
|
} else {
|
|
// Content first, padded to fill height
|
|
for i := range listHeight {
|
|
if i < len(listLines) {
|
|
lines = append(lines, padLine(listLines[i]))
|
|
} else {
|
|
lines = append(lines, padLine(""))
|
|
}
|
|
}
|
|
// Separator (no vertical split for top/bottom preview)
|
|
lines = append(lines, hLine(leftT, rightT, 0))
|
|
// Then preview
|
|
for _, line := range previewLines[:previewH] {
|
|
lines = append(lines, padLine(line))
|
|
}
|
|
}
|
|
|
|
case PreviewLeft, PreviewRight:
|
|
// Horizontal split: |leftContent|rightContent|
|
|
// innerWidth = leftW + 1 (middle border) + rightW
|
|
var leftW, rightW int
|
|
if m.config.PreviewPosition == PreviewLeft {
|
|
leftW = m.previewSize()
|
|
rightW = innerWidth - leftW - 1
|
|
} else {
|
|
rightW = m.previewSize()
|
|
leftW = innerWidth - rightW - 1
|
|
}
|
|
|
|
// Prepare preview lines (wrap text instead of truncating)
|
|
var previewLines []string
|
|
if previewContent != "" {
|
|
previewW := leftW
|
|
if m.config.PreviewPosition == PreviewRight {
|
|
previewW = rightW
|
|
}
|
|
previewLines = wrapPreviewContent(previewContent, previewW)
|
|
}
|
|
for len(previewLines) < listHeight {
|
|
previewLines = append(previewLines, "")
|
|
}
|
|
|
|
// Helper to truncate/pad to width
|
|
fitToWidth := func(s string, w int, isPreview bool) string {
|
|
sw := lipgloss.Width(s)
|
|
if sw > w {
|
|
if isPreview {
|
|
// Preview is already wrapped, just pad
|
|
return s + strings.Repeat(" ", w-sw)
|
|
}
|
|
// List content may have ANSI codes, use lipgloss for safe truncation
|
|
return lipgloss.NewStyle().MaxWidth(w-1).Render(s) + ellipsis
|
|
}
|
|
return s + strings.Repeat(" ", w-sw)
|
|
}
|
|
|
|
// Build combined lines
|
|
for i := range listHeight {
|
|
var leftContent, rightContent string
|
|
var leftIsPreview, rightIsPreview bool
|
|
|
|
if m.config.PreviewPosition == PreviewLeft {
|
|
leftContent = previewLines[i]
|
|
leftIsPreview = true
|
|
if i < len(listLines) {
|
|
rightContent = listLines[i]
|
|
}
|
|
} else {
|
|
if i < len(listLines) {
|
|
leftContent = listLines[i]
|
|
}
|
|
rightContent = previewLines[i]
|
|
rightIsPreview = true
|
|
}
|
|
|
|
leftContent = fitToWidth(leftContent, leftW, leftIsPreview)
|
|
rightContent = fitToWidth(rightContent, rightW, rightIsPreview)
|
|
|
|
line := borderStyle.Render(vertical) + leftContent + borderStyle.Render(vertical) + rightContent + borderStyle.Render(vertical)
|
|
lines = append(lines, line)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bottom border
|
|
lines = append(lines, hLineBottom(bottomLeft, bottomRight, vSplitPos))
|
|
|
|
// Combine box with prompt
|
|
fullView := strings.Join(lines, "\n") + "\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
|
|
}
|