Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,21 +53,22 @@ type Config struct {
ExecList []string `toml:"exec_list" yaml:"exec_list"` // Simpler form, see [Execute Lifecycle]
LogLevel string `toml:"log_level" yaml:"log_level"`
Debounce int `toml:"debounce" yaml:"debounce"`
EnablePause bool `toml:"enable_pause" yaml:"enable_pause"` // Use Ctrl+Z to toggle pause/resume instead of suspending (Unix only)
Callback func(*EventCallback) EventHandle
Slog *slog.Logger
}

type Ignore struct {
Dir []string `toml:"dir" yaml:"dir"` // Directories to ignore, e.g. node_modules
File []string `toml:"file" yaml:"file"` // Files to ignore
WatchedExten []string `toml:"watched_extension" yaml:"watched_extension"` // Extensions to watch; anything else is ignored
WatchedExten []string `toml:"watched_extension" yaml:"watched_extension"` // Extensions to watch; anything else is ignored. Empty watches all files.
IgnoreGit bool `toml:"git" yaml:"git"` // When true, .gitignore entries in the root are also ignored
}

type Execute struct {
Cmd string `toml:"cmd" yaml:"cmd"` // Command to run
ChangeDir string `toml:"dir" yaml:"dir"` // Directory to run in, relative to root_path
DelayNext int `toml:"delay_next" yaml:"delay_next"` // Delay in milliseconds before running
DelayNext int `toml:"delay_next" yaml:"delay_next"` // Pause in milliseconds after this step, before the next one starts
Type ExecuteType `toml:"type" yaml:"type"` // background | once | blocking | primary
}
```
Expand Down
11 changes: 7 additions & 4 deletions cmd/refresh/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type cliFlags struct {
debounce int
version bool
gitIgnore bool
trapSuspend bool
ignoreDir string
ignoreFile string
ignoreExt string
Expand All @@ -40,6 +41,7 @@ func parseFlags(args []string) (cliFlags, error) {
fs.IntVar(&f.debounce, "d", 1000, "Debounce time in milliseconds")
fs.BoolVar(&f.version, "v", false, "Print version")
fs.BoolVar(&f.gitIgnore, "git", false, "Read .gitignore in the root")
fs.BoolVar(&f.trapSuspend, "pause", false, "Use Ctrl+Z to toggle pause/resume instead of suspending")
if err := fs.Parse(args); err != nil {
return f, err
}
Expand All @@ -64,10 +66,11 @@ func splitList(csv string) []string {
// toConfig maps the flags to an engine.Config (used when no config file is given).
func (f cliFlags) toConfig() refresh.Config {
return refresh.Config{
RootPath: f.rootPath,
ExecList: splitList(f.execCommand),
LogLevel: f.logLevel,
Debounce: f.debounce,
RootPath: f.rootPath,
ExecList: splitList(f.execCommand),
LogLevel: f.logLevel,
Debounce: f.debounce,
EnablePause: f.trapSuspend,
Ignore: refresh.Ignore{
File: splitList(f.ignoreFile),
Dir: splitList(f.ignoreDir),
Expand Down
25 changes: 21 additions & 4 deletions engine/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,13 @@ type Config struct {
ExecList []string `toml:"exec_list" yaml:"exec_list"`
LogLevel string `toml:"log_level" yaml:"log_level"`
Debounce int `toml:"debounce" yaml:"debounce"`
Callback func(*EventCallback) EventHandle
Slog *slog.Logger
// EnablePause, when true, repurposes the terminal suspend key (Ctrl+Z /
// SIGTSTP) as a pause/resume toggle: the first press pauses reloads, the next
// resumes. This overrides the shell's normal "suspend to background" behavior,
// so it is opt-in. No-op on platforms without SIGTSTP (Windows).
EnablePause bool `toml:"enable_pause" yaml:"enable_pause"`
Callback func(*EventCallback) EventHandle
Slog *slog.Logger
}

func DefaultEngineConfig() Config {
Expand Down Expand Up @@ -54,6 +59,13 @@ func (c *Config) WithDebounce(value int) *Config {
return c
}

// WithEnablePause opts into using the terminal suspend key (Ctrl+Z / SIGTSTP)
// as a pause/resume toggle instead of suspending the process.
func (c *Config) WithEnablePause(truthy bool) *Config {
c.EnablePause = truthy
return c
}

func (c *Config) WithIgnore(ignore Ignore) *Config {
c.Ignore = ignore
return c
Expand Down Expand Up @@ -129,6 +141,11 @@ func (engine *Engine) verifyConfig() error {
if engine.Config.RootPath == "" {
return errors.New("root path is required")
}
// The background block always runs as a background process; a type set on it
// is dropped, so warn rather than silently ignore it.
if t := engine.Config.BackgroundStruct.Type; t != "" && t != process.Background {
slog.Warn("background.type is ignored; the background command always runs as a background process", "ignored_type", t)
}
engine.normalizeExecutes()
if err := engine.verifyExecute(); err != nil {
return err
Expand Down Expand Up @@ -240,9 +257,9 @@ func (e *Engine) generateProcess() {
// A configured background command is started once at startup, survives
// reloads, and is killed on shutdown — regardless of any Type set on it.
if bg := e.Config.BackgroundStruct; bg.Cmd != "" {
_ = e.ProcessManager.AddProcess(bg.Cmd, string(process.Background), bg.ChangeDir)
_ = e.ProcessManager.AddProcessWithDelay(bg.Cmd, string(process.Background), bg.ChangeDir, bg.DelayNext)
}
for _, ex := range e.Config.ExecStruct {
_ = e.ProcessManager.AddProcess(ex.Cmd, string(ex.Type), ex.ChangeDir)
_ = e.ProcessManager.AddProcessWithDelay(ex.Cmd, string(ex.Type), ex.ChangeDir, ex.DelayNext)
}
}
16 changes: 16 additions & 0 deletions engine/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,22 @@ func TestBackgroundStructBecomesProcess(t *testing.T) {
}
}

func TestBackgroundTypeIsIgnored(t *testing.T) {
// A type set on the background block is dropped: the background command always
// registers as a background process regardless of what Type was configured.
eng, err := NewEngineFromConfig(Config{
RootPath: ".",
BackgroundStruct: process.Execute{Cmd: "echo bg", Type: process.Primary},
ExecStruct: []process.Execute{{Cmd: "./app", Type: process.Primary}},
})
if err != nil {
t.Fatal(err)
}
if got := eng.ProcessManager.Processes[0].Type; got != process.Background {
t.Errorf("background process type = %q, want %q (type on the background block must be ignored)", got, process.Background)
}
}

func TestNormalizeExecutesPrefersStruct(t *testing.T) {
e := &Engine{Config: Config{
RootPath: ".",
Expand Down
36 changes: 31 additions & 5 deletions engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"os"
"os/signal"
"syscall"
"time"

"github.com/atterpac/refresh/process"
)
Expand Down Expand Up @@ -69,9 +68,6 @@ func (engine *Engine) Start() error {
if engine.Config.Ignore.IgnoreGit {
engine.Config.Ignore.gitPatterns = readGitIgnore(engine.Config.RootPath)
}
if delay := engine.Config.BackgroundStruct.DelayNext; delay > 0 {
time.Sleep(time.Duration(delay) * time.Millisecond)
}

ctx, cancel := context.WithCancel(context.Background())
engine.ctx = ctx
Expand All @@ -95,15 +91,45 @@ func (engine *Engine) Start() error {
return err
}

// Optional pause/resume toggle, driven by the suspend key (Ctrl+Z). The
// buffered, non-blocking send keeps the toggle from blocking the signal
// goroutine and coalesces rapid presses.
toggle := make(chan struct{}, 1)
if engine.Config.EnablePause {
engine.trapControlSignals(toggle)
}

// Supervisor loop: the only goroutine that drives process lifecycle, so the
// process manager needs no locking around its runtime state.
// process manager needs no locking around its runtime state. paused and
// pending live here for the same reason.
paused := false
pending := false // a reload arrived while paused

for {
select {
case <-ctx.Done():
engine.ProcessManager.Shutdown()
slog.Info("refresh stopped")
return nil
case <-toggle:
paused = !paused
if paused {
slog.Warn("refresh paused — file changes ignored until resumed")
continue
}
slog.Warn("refresh resumed")
if pending {
pending = false
slog.Info("applying change made while paused, reloading")
if err := engine.ProcessManager.Reload(ctx); err != nil {
slog.Error("reload failed", "err", err)
}
}
case <-reload:
if paused {
pending = true
continue
}
slog.Info("change detected, reloading")
if err := engine.ProcessManager.Reload(ctx); err != nil {
slog.Error("reload failed", "err", err)
Expand Down
6 changes: 6 additions & 0 deletions engine/ignore.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ func (i *Ignore) shouldIgnore(path string) bool {
}

func (i *Ignore) isWatchedExtension(path string) bool {
// No configured filter means watch everything; this is the default config
// and the bare-CLI case, so it must reload rather than ignore every change.
if len(i.WatchedExten) == 0 {
return true
}

ext := filepath.Ext(path)
if ext == "" {
return false
Expand Down
12 changes: 12 additions & 0 deletions engine/ignore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,18 @@ func Test_isWatchedExtension(t *testing.T) {
watchedExten: []string{"*.go", "*.js"},
wantIsWatched: false,
},
{
name: "empty filter watches all extensions (default config)",
path: "/some/path/file.go",
watchedExten: nil,
wantIsWatched: true,
},
{
name: "empty filter watches extensionless files too",
path: "/some/path/Makefile",
watchedExten: nil,
wantIsWatched: true,
},
}

for _, tt := range tests {
Expand Down
27 changes: 27 additions & 0 deletions engine/signals_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//go:build linux || darwin

package engine

import (
"os"
"os/signal"
"syscall"
)

// trapControlSignals repurposes the terminal suspend key (Ctrl+Z / SIGTSTP) as a
// pause/resume toggle. Trapping SIGTSTP overrides the kernel's default suspend
// disposition, so the process is never actually stopped; each delivery instead
// pokes toggle, which the supervisor loop interprets as flip-paused. The send is
// non-blocking so a press while a toggle is already queued is coalesced.
func (engine *Engine) trapControlSignals(toggle chan<- struct{}) {
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGTSTP)
go func() {
for range ch {
select {
case toggle <- struct{}{}:
default:
}
}
}()
}
8 changes: 8 additions & 0 deletions engine/signals_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//go:build windows

package engine

// trapControlSignals is a no-op on Windows: there is no SIGTSTP/Ctrl+Z suspend
// signal to repurpose as a pause/resume toggle, so enabling Config.TrapSuspend
// has no effect on this platform.
func (engine *Engine) trapControlSignals(chan<- struct{}) {}
34 changes: 34 additions & 0 deletions engine/watch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,40 @@ func TestWatcherDetectsNestedSubdirectoryChanges(t *testing.T) {
}
}

// TestWatcherDefaultConfigReloadsAnyFile guards the default/empty-filter path:
// with no WatchedExten configured (DefaultEngineConfig and the bare CLI), every
// change must still trigger a reload. A regression here previously made the
// out-of-the-box config silently watch nothing.
func TestWatcherDefaultConfigReloadsAnyFile(t *testing.T) {
root := t.TempDir()
e := &Engine{Config: Config{
RootPath: root,
Debounce: 100,
Ignore: Ignore{}, // zero value: no extension filter
}}
e.ProcessManager = process.NewProcessManager()
if err := e.ProcessManager.SetRootDirectory(root); err != nil {
t.Fatal(err)
}

reload := make(chan struct{}, 16)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := e.startWatcher(ctx, reload); err != nil {
t.Fatalf("startWatcher: %v", err)
}

if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
time.Sleep(300 * time.Millisecond)
cancel()

if got := len(reload); got != 1 {
t.Errorf("default config produced %d reloads for a .go edit, want 1", got)
}
}

func TestWatcherIgnoresUnwatchedExtensions(t *testing.T) {
root := t.TempDir()
e := newWatchTestEngine(t, root, 100)
Expand Down
4 changes: 3 additions & 1 deletion examples/basic/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@ config:
root_path: "."
log_level: "debug"
debounce: 500
# enable_pause: true # Unix only: Ctrl+Z toggles pause/resume instead of suspending

ignore:
dir: [".git", "node_modules", "vendor", "bin"]
file: [".gitignore", ".DS_Store"]
watched_extension: ["*.go"]
watched_extension: ["*.go"] # omit to watch every file
git: true

# Executes run in order. type is one of: background | once | blocking | primary
executes:
- cmd: "go build -o ./bin/app ./app"
type: "blocking"
# delay_next: 500 # pause in ms after this step, before the next starts

- cmd: "./bin/app"
type: "primary"
2 changes: 1 addition & 1 deletion process/execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
type Execute struct {
Cmd string `toml:"cmd" yaml:"cmd"` // Execute command
ChangeDir string `toml:"dir" yaml:"dir"` // If directory needs to be changed to call this command relative to the root path
DelayNext int `toml:"delay_next" yaml:"delay_next"` // Delay in milliseconds before running command
DelayNext int `toml:"delay_next" yaml:"delay_next"` // Pause in ms held after this step completes, before the next process starts
// Type can have one of a few types to define how it reacts to a file change
// background -- runs once at startup and is killed when refresh is canceled
// once -- runs once at refresh startup but is blocking
Expand Down
Loading
Loading