Use shell runner

This commit is contained in:
IamTheFij 2024-10-21 13:35:53 -07:00
parent 4e05f18091
commit 10c345bc96
5 changed files with 80 additions and 78 deletions

1
.gitignore vendored
View File

@ -27,3 +27,4 @@ _testmain.go
.DS_Store
gomodoro
coverage.out
dist/

View File

@ -2,7 +2,7 @@ VERSION ?= $(shell git describe --tags --dirty)
GOFILES = *.go go.mod go.sum
APP_NAME = gomodoro
# Multi-arch targets are generated from this
TARGET_ALIAS = $(APP_NAME)-linux-amd64 $(APP_NAME)-linux-arm $(APP_NAME)-linux-arm64 $(APP_NAME)-darwin-amd64
TARGET_ALIAS = $(APP_NAME)-linux-amd64 $(APP_NAME)-linux-arm $(APP_NAME)-linux-arm64 $(APP_NAME)-darwin-amd64 $(APP_NAME)-darwin-arm64
TARGETS = $(addprefix dist/,$(TARGET_ALIAS))
#
# Default make target will run tests

3
go.mod
View File

@ -3,9 +3,10 @@ module git.iamthefij.com/iamthefij/gomodoro
go 1.21.4
require (
git.iamthefij.com/iamthefij/go-shell-runner v0.1.0
git.iamthefij.com/iamthefij/slog v1.3.0
github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/bubbletea v1.1.1
github.com/charmbracelet/harmonica v0.2.0
github.com/charmbracelet/lipgloss v0.13.0
github.com/urfave/cli/v2 v2.27.5
)

96
main.go
View File

@ -1,14 +1,16 @@
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"os/signal"
"strconv"
"strings"
"time"
shellrunner "git.iamthefij.com/iamthefij/go-shell-runner"
"git.iamthefij.com/iamthefij/slog"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
@ -18,7 +20,9 @@ import (
type screen int
var version = "dev"
var (
version = "dev"
)
const (
inputScreen screen = iota
@ -31,13 +35,12 @@ type model struct {
progressBar progress.Model
state string
intervalNum int
timer *time.Timer
remaining time.Duration
totalTime time.Duration
currentScreen screen
intervals int
breakTime time.Duration
focusTime time.Duration
startTime time.Time
onFocusStart []string
onFocusEnd []string
onIntervalEnd []string
@ -46,6 +49,7 @@ type model struct {
width int
height int
err error
shellrunner *shellrunner.ShellRunner
}
func initialModel(fullscreen bool, colorLeft string, colorRight string) model {
@ -92,10 +96,10 @@ func initialModel(fullscreen bool, colorLeft string, colorRight string) model {
state: "stopped",
intervalNum: 1,
remaining: 0,
totalTime: 0,
currentScreen: inputScreen, // Start on input screen
isFocus: true,
fullscreen: fullscreen,
shellrunner: shellrunner.NewShellRunner(),
}
}
@ -106,13 +110,24 @@ func (m model) Init() tea.Cmd {
go func() {
<-c
m.shellrunner.KillWithTimeout(10 * time.Second)
fmt.Println("\nExiting...")
os.Exit(0)
}()
m.shellrunner.Start()
return tea.Batch(tea.WindowSize(), textinput.Blink)
}
func (m model) totalTime() time.Duration {
if m.isFocus {
return m.focusTime
}
return m.breakTime
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
@ -166,13 +181,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.focusTime = focusTime
m.breakTime = breakTime
m.remaining = focusTime
m.totalTime = focusTime
m.state = "Focus"
m.timer = time.NewTimer(time.Second)
m.currentScreen = timerScreen
m.startTime = time.Now()
// Run onFocusStart commands
m.runCommands(m.onFocusStart)
m.startCommands(m.onFocusStart)
return m, tick()
} else {
@ -186,34 +200,32 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case timeMsg:
// Handle timer update for each second
// TODO: Use absolute times to tick down remaining time rather than incrementing seconds
m.remaining -= time.Second
m.remaining = m.totalTime() - time.Since(m.startTime)
if m.remaining < 0 {
if m.isFocus {
// Focus period ends, switch to break
m.isFocus = false
m.remaining = m.remaining + m.breakTime
m.totalTime = m.breakTime
m.state = "Break"
m.startTime = time.Now()
// Run onFocusEnd commands
m.runCommands(m.onFocusEnd)
m.startCommands(m.onFocusEnd)
} else {
// Break ends, switch back to focus or next interval
m.isFocus = true
m.intervalNum++
m.runCommands(m.onIntervalEnd)
m.startCommands(m.onIntervalEnd)
if m.intervalNum > m.intervals {
// All intervals completed
return m, tea.Quit
}
m.remaining = m.remaining + m.focusTime
m.totalTime = m.focusTime
m.state = "Focus"
m.startTime = time.Now()
// Run onFocusStart commands
m.runCommands(m.onFocusStart)
m.startCommands(m.onFocusStart)
}
return m, tick()
@ -232,6 +244,21 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
}
for {
result := m.shellrunner.GetResults()
if result == nil {
break
}
if result.ReturnCode != 0 {
m.err = errors.Join(m.err, fmt.Errorf("error running command: %s", result.Command))
// TODO: Create an error view
slog.Errorf("Error running command: %s", result.Command)
slog.Errorf("Error: %s", result.Output)
slog.Errorf("Error: %s", result.ErrOutput)
}
}
return m, tea.Batch(cmds...)
}
@ -290,10 +317,10 @@ func (m model) timerScreenView() string {
m.progressBar.Width = m.width - progressBarPadding
}
progressView := m.progressBar.ViewAs(float64(m.totalTime-m.remaining) / float64(m.totalTime))
progressView := m.progressBar.ViewAs(float64(m.totalTime()-m.remaining) / float64(m.totalTime()))
status := lipgloss.NewStyle().Bold(true).Render(fmt.Sprintf("State: %s", m.state))
intervalInfo := fmt.Sprintf("Interval: %d / %d", m.intervalNum, m.intervals)
timeLeft := fmt.Sprintf("Time left: %s", m.remaining.String())
timeLeft := fmt.Sprintf("Time left: %s", m.remaining.Round(time.Second).String())
timerView := fmt.Sprintf("%s\n%s\n%s\n\n%s", status, intervalInfo, timeLeft, progressView)
@ -305,25 +332,23 @@ func (m model) timerScreenView() string {
}
// Helper to update input focus
func (m *model) updateFocus() {
for i := range m.inputs {
if i == m.focusIndex {
m.inputs[i].Focus()
} else {
m.inputs[i].Blur()
}
}
}
/*
* func (m *model) updateFocus() {
* for i := range m.inputs {
* if i == m.focusIndex {
* m.inputs[i].Focus()
* } else {
* m.inputs[i].Blur()
* }
* }
* }
*/
// Helper to run shell commands
func (m *model) runCommands(commands []string) {
func (m *model) startCommands(commands []string) {
for _, cmdStr := range commands {
cmd := exec.Command("sh", "-c", cmdStr)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf("Error running command: %v\n", err)
if err := m.shellrunner.AddCommand(cmdStr, nil); err != nil {
m.err = fmt.Errorf("error adding command: %w", err)
}
}
}
@ -400,9 +425,6 @@ func main() {
return nil
}
fmt.Printf("color-left: %s\n", c.String("color-left"))
fmt.Printf("color-right: %s\n", c.String("color-right"))
m := initialModel(c.Bool("fullscreen"), c.String("color-left"), c.String("color-right"))
// Collect command flags

View File

@ -1,16 +1,22 @@
package main
import (
"bytes"
"io"
"os"
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
)
type compareFunc[T comparable] func(T, T) bool
func assertFunc[T comparable](t *testing.T, compare compareFunc[T], actual, expected T, msg string) {
t.Helper()
if !compare(actual, expected) {
t.Errorf("%s: expected %v, got %v", msg, expected, actual)
}
}
// assertEqual checks if two values are equal and reports an error if they are not.
func assertEqual(t *testing.T, actual, expected interface{}, msg string) {
t.Helper()
@ -54,37 +60,12 @@ func TestParseDuration(t *testing.T) {
// TestRunCommands tests the runCommands function
func TestRunCommands(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
m := initialModel(false, "#ffdd57", "#57ddff")
m.shellrunner.Start()
// Redirect stdout and stderr to the pipe
oldStdout := os.Stdout
oldStderr := os.Stderr
os.Stdout = w
os.Stderr = w
m.startCommands([]string{"echo Hello, World!"})
t.Cleanup(func() {
os.Stdout = oldStdout
os.Stderr = oldStderr
w.Close()
})
m := model{}
m.runCommands([]string{"echo Hello, World!"})
w.Close()
var buf bytes.Buffer
if _, err = io.Copy(&buf, r); err != nil {
t.Fatal(err)
}
output := buf.String()
assertEqual(t, strings.Contains(output, "Hello, World!"), true, "Expected output to contain 'Hello, World!'")
m.shellrunner.Stop()
}
// TestInputView tests the Update method of the model for the input view
@ -126,7 +107,7 @@ func TestInputView(t *testing.T) {
assertEqual(t, m.err, nil, "Expected no error")
assertNotEqual(t, resultCmd, nil, "Expected resultCmd to be not nil")
assertEqual(t, m.currentScreen, timerScreen, "Expected currentScreen to be timerScreen")
assertEqual(t, m.remaining, 10*time.Minute, "Expected remaining to be 10 minutes")
assertEqual(t, m.remaining.Round(time.Second), 10*time.Minute, "Expected remaining to be 10 minutes")
// Test timer view
m.View()
@ -136,21 +117,18 @@ func TestInputView(t *testing.T) {
m = updatedModel.(model)
assertEqual(t, m.state, "Focus", "Expected state to be 'Focus'")
assertEqual(t, m.remaining, 9*time.Minute+59*time.Second, "Expected remaining to be 9 minutes 59 seconds")
// Test switch to break time
m.remaining = 0
m.startTime = m.startTime.Add(-10 * time.Minute)
updatedModel, _ = m.Update(oneSec)
m = updatedModel.(model)
assertEqual(t, m.state, "Break", "Expected state to be 'Break'")
assertEqual(t, m.remaining, 9*time.Minute+59*time.Second, "Expected remaining to be 9 minutes 59 seconds")
// Switch back to focus time
m.remaining = 0
m.startTime = m.startTime.Add(-10 * time.Minute)
updatedModel, _ = m.Update(oneSec)
m = updatedModel.(model)
assertEqual(t, m.state, "Focus", "Expected state to be 'Focus'")
assertEqual(t, m.remaining, 9*time.Minute+59*time.Second, "Expected remaining to be 9 minutes 59 seconds")
}