internal/watch/debounce.go

// Package watch: debouncer for fsnotify events. Editors like nvim write the
// backup file, rename, then chmod -- that's three events for one "save".
// 150ms is the smallest window that collapses them on my laptop without
// feeling laggy. See commit 4ed509d.
package watch

import (
	"sync"
	"time"
)

// Debouncer coalesces calls to Trigger into a single Fn invocation. The
// scheduled call is reset every time Trigger runs within Window.
type Debouncer struct {
	Window time.Duration
	Fn     func()

	mu    sync.Mutex
	timer *time.Timer
	done  chan struct{}
}

// Trigger asks the debouncer to call Fn after Window of silence. If Trigger
// is called again before Window elapses, the timer is reset.
func (d *Debouncer) Trigger() {
	d.mu.Lock()
	defer d.mu.Unlock()

	if d.timer != nil {
		d.timer.Reset(d.Window)
		return
	}
	d.done = make(chan struct{})
	d.timer = time.AfterFunc(d.Window, func() {
		d.mu.Lock()
		d.timer = nil
		ch := d.done
		d.mu.Unlock()
		d.Fn()
		close(ch)
	})
}

// Wait blocks until the most recently scheduled Fn has returned. Safe to call
// with no outstanding trigger -- returns immediately.
func (d *Debouncer) Wait() {
	d.mu.Lock()
	ch := d.done
	d.mu.Unlock()
	if ch == nil {
		return
	}
	<-ch
}

// Stop cancels any pending call. Any in-flight Fn is allowed to finish.
func (d *Debouncer) Stop() {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.timer != nil {
		d.timer.Stop()
		d.timer = nil
	}
}

// New creates a Debouncer with sane defaults. Zero window becomes 150ms.
func New(window time.Duration, fn func()) *Debouncer {
	if window <= 0 {
		window = 150 * time.Millisecond
	}
	return &Debouncer{Window: window, Fn: fn}
}