Skip to content

Commit

Permalink
fix: Watch dirs recursively and dynamically
Browse files Browse the repository at this point in the history
Automatically start watching created directories.
Automatically stop watching removed/renamed directories.
  • Loading branch information
romshark committed Jun 14, 2024
1 parent 10a256f commit 677f1cb
Show file tree
Hide file tree
Showing 8 changed files with 801 additions and 351 deletions.
25 changes: 20 additions & 5 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,26 @@ import (
"encoding"
"flag"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"time"

"github.com/romshark/yamagiconf"
)

var config Config

type Config struct {
serverOutPath string // Initialized from os.Getwd and os.TempDir

App struct {
// DirSrcRoot is the source root directory for the application server.
DirSrcRoot string `yaml:"dir-src-root" validate:"dirpath,required"`

dirSrcRootAbsolute string // Initialized from DirSrcRoot

// DirCmd is the server cmd directory containing the `main` function.
DirCmd string `yaml:"dir-cmd" validate:"dirpath,required"`

Expand Down Expand Up @@ -62,11 +71,6 @@ type Config struct {
} `yaml:"tls"`
}

var (
serverOutPath string
config Config
)

func mustParseConfig() {
var fConfigPath string
flag.StringVar(&fConfigPath, "config", "./templier.yml", "config file path")
Expand All @@ -89,6 +93,17 @@ func mustParseConfig() {
panic(fmt.Errorf("reading config file: %w", err))
}
}

workingDir, err := os.Getwd()
if err != nil {
panic(fmt.Errorf("getting working dir: %w", err))
}
config.serverOutPath = path.Join(os.TempDir(), workingDir)

config.App.dirSrcRootAbsolute, err = filepath.Abs(config.App.DirSrcRoot)
if err != nil {
panic(fmt.Errorf("getting absolute path for app.dir-src-root: %w", err))
}
}

type SpaceSeparatedList []string
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
github.com/a-h/templ v0.2.707
github.com/fatih/color v1.17.0
github.com/fsnotify/fsnotify v1.7.0
github.com/gorilla/websocket v1.5.2
github.com/gorilla/websocket v1.5.3
github.com/romshark/yamagiconf v0.10.4
github.com/stretchr/testify v1.9.0
)
Expand Down
6 changes: 2 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ github.com/go-playground/validator/v10 v10.21.0/go.mod h1:dbuPbCMFw/DrkbEynArYaC
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/websocket v1.5.2 h1:qoW6V1GT3aZxybsbC6oLnailWnB+qTMVwMreOso9XUw=
github.com/gorilla/websocket v1.5.2/go.mod h1:0n9H61RBAcf5/38py2MCYbxzPIY9rOkpvvMT24Rqs30=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
Expand Down Expand Up @@ -70,8 +70,6 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
Expand Down
10 changes: 8 additions & 2 deletions debounce.go → internal/debounce/debounce.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
package main
package debounce

import (
"context"
"sync"
"time"
)

func NewDebouncedSync(duration time.Duration) (
// NewSync creates a new concurrency-safe debouncer.
func NewSync(duration time.Duration) (
runDebouncer func(ctx context.Context), trigger func(fn func()),
) {
if duration == 0 {
// Debounce disabled, execute fn immediately.
return func(context.Context) { /*Noop*/ }, func(fn func()) { fn() }
}

var lock sync.Mutex
var fn func()
ticker := time.NewTicker(duration)
Expand Down
206 changes: 206 additions & 0 deletions internal/watcher/watcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package watcher

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"

"github.com/fsnotify/fsnotify"
)

// Watcher is a recursive file watcher.
type Watcher struct {
lock sync.Mutex
closed bool
watchedDirs map[string]struct{} // dir path -> closer channel
onChange func(ctx context.Context, e fsnotify.Event)
watcher *fsnotify.Watcher
}

// New creates a new file watcher that executes onChange for any
// remove/create/change/chmod filesystem event.
// onChange will receive the ctx that was passed to Run.
func New(onChange func(ctx context.Context, e fsnotify.Event)) (*Watcher, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
return &Watcher{
watchedDirs: make(map[string]struct{}),
onChange: onChange,
watcher: watcher,
}, nil
}

var ErrClosed = errors.New("closed")

// RangeWatchedDirs calls fn for every currently watched directory.
// Noop if the watcher is closed.
func (w *Watcher) RangeWatchedDirs(fn func(path string) (continueIter bool)) {
w.lock.Lock()
defer w.lock.Unlock()
if w.closed {
return
}
for p := range w.watchedDirs {
if !fn(p) {
return
}
}
}

// Close stops watching everything and closes the watcher.
// Noop if the watcher is closed.
func (w *Watcher) Close() error {
w.lock.Lock()
defer w.lock.Unlock()
if w.closed {
return nil
}
w.closed = true
return w.watcher.Close()
}

// Run runs the watcher.
// Returns ErrClosed if already closed.
func (w *Watcher) Run(ctx context.Context) error {
w.lock.Lock()
if w.closed {
w.lock.Unlock()
return ErrClosed
}
w.lock.Unlock()

defer w.Close()
for {
select {
case <-ctx.Done():
return ctx.Err() // Watching canceled
case e := <-w.watcher.Events:
switch e.Op {
case fsnotify.Create, fsnotify.Remove, fsnotify.Rename:
if w.isDirEvent(e) {
switch e.Op {
case fsnotify.Create:
// New sub-directory was created, start watching it.
if err := w.Add(e.Name); err != nil {
return fmt.Errorf("adding created directory: %w", err)
}
case fsnotify.Remove, fsnotify.Rename:
// Sub-directory was removed or renamed, stop watching it.
// A new create notification will readd it.
if err := w.Remove(e.Name); err != nil {
return fmt.Errorf("removing directory: %w", err)
}
}
}
case 0:
continue
}
w.onChange(ctx, e)
case err := <-w.watcher.Errors:
if err != nil {
return fmt.Errorf("watching: %w", err)
}
}
}
}

// Add starts watching the directory and all of its subdirectories recursively.
// Returns ErrClosed if the watcher is already closed.
func (w *Watcher) Add(dir string) error {
w.lock.Lock()
defer w.lock.Unlock()
if w.closed {
return ErrClosed
}
err := forEachDir(dir, func(dir string) error {
if _, ok := w.watchedDirs[dir]; ok {
return errAlreadyWatched // Directory already watched
}
w.watchedDirs[dir] = struct{}{}
return w.watcher.Add(dir)
})
if err == errAlreadyWatched {
return nil
}
return err
}

var errAlreadyWatched = errors.New("directory already watched")

// Remove stops watching the directory and all of its subdirectories recursively.
// Returns ErrClosed if the watcher is already closed.
func (w *Watcher) Remove(dir string) error {
w.lock.Lock()
defer w.lock.Unlock()
if w.closed {
return ErrClosed
}

if _, ok := w.watchedDirs[dir]; !ok {
return nil
}
delete(w.watchedDirs, dir)
if err := w.removeWatcher(dir); err != nil {
return err
}

// Stop all sub-directory watchers
for p := range w.watchedDirs {
if strings.HasPrefix(p, dir) {
delete(w.watchedDirs, p)
if err := w.removeWatcher(dir); err != nil {
return err
}
}
}

return nil
}

// removeWatcher ignores ErrNonExistentWatch when removing a watcher.
func (w *Watcher) removeWatcher(dir string) error {
if err := w.watcher.Remove(dir); err != nil {
if !errors.Is(err, fsnotify.ErrNonExistentWatch) {
return err
}
}
return nil
}

func (w *Watcher) isDirEvent(e fsnotify.Event) bool {
switch e.Op {
case fsnotify.Create, fsnotify.Write, fsnotify.Chmod:
fileInfo, err := os.Stat(e.Name)
if err != nil {
return false
}
return fileInfo.IsDir()
}
_, ok := w.watchedDirs[e.Name]
return ok
}

// forEachDir executes fn for every subdirectory of pathDir,
// including pathDir itself, recursively.
func forEachDir(pathDir string, fn func(dir string) error) error {
// Use filepath.Walk to traverse directories
err := filepath.Walk(pathDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err // Stop walking the directory tree.
}
if !info.IsDir() {
return nil // Continue walking.
}
if err = fn(path); err != nil {
return err
}
return nil
})
return err
}
Loading

0 comments on commit 677f1cb

Please sign in to comment.