Skip to content

Commit

Permalink
internal/cli/run: trigger full reloads on non-update events (#212)
Browse files Browse the repository at this point in the history
* wip recompile triggers. Closes: #138

* internal/cli/run: trigger full reloads on non-update events

* add test

* add comments

* fix watch test
  • Loading branch information
matthewmueller committed Jul 17, 2022
1 parent 34c1203 commit 4ec496d
Show file tree
Hide file tree
Showing 6 changed files with 280 additions and 101 deletions.
6 changes: 3 additions & 3 deletions example/basic/view/edit.svelte
Expand Up @@ -5,9 +5,9 @@
<h1>Edit Post</h1>

<form method="post" action={`/${post.id || 0}`}>
<input type="hidden" name="_method" value="patch">
<!-- Add input fields here -->
<input type="submit" value="Update Post" />
<input type="hidden" name="_method" value="patch" />
<!-- Add input fields here -->
<input type="submit" value="Update Post" />
</form>

<br />
Expand Down
110 changes: 110 additions & 0 deletions framework/view/view_test.go
Expand Up @@ -3,6 +3,8 @@ package view_test
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"testing"

Expand Down Expand Up @@ -245,3 +247,111 @@ func TestConsoleError(t *testing.T) {
// https://github.com/kuoruan/v8go-polyfills
t.SkipNow()
}

func TestRenameView(t *testing.T) {
is := is.New(t)
ctx := context.Background()
dir := t.TempDir()
td := testdir.New(dir)
td.Files["controller/controller.go"] = `
package controller
type Controller struct {}
func (c *Controller) Show() (id int) { return 10 }
`
td.Files["view/show.svelte"] = `
<script>
export let id = 0
</script>
<h1>{id}</h1>
`
td.NodeModules["svelte"] = versions.Svelte
td.NodeModules["livebud"] = "*"
is.NoErr(td.Write(ctx))
cli := testcli.New(dir)
app, err := cli.Start(ctx, "run")
is.NoErr(err)
defer app.Close()
hot, err := app.Hot("/bud/hot/view/show.svelte")
is.NoErr(err)
defer hot.Close()
res, err := app.Get("/10")
is.NoErr(err)
is.NoErr(res.DiffHeaders(`
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: text/html
`))
is.In(res.Body().String(), "<h1>10</h1>")
// Rename the file
is.NoErr(os.Rename(
filepath.Join(dir, "view/show.svelte"),
filepath.Join(dir, "view/_show.svele"),
))
// Wait for the app to be ready again
app.Ready(ctx)
// Check that we received a hot reload event
event, err := hot.Next(ctx)
is.NoErr(err)
is.In(string(event.Data), `{"reload":true}`)
// Should change
res, err = app.Get("/10")
is.NoErr(err)
is.NoErr(res.DiffHeaders(`
HTTP/1.1 200 OK
Content-Type: application/json
`))
is.Equal(res.Body().String(), "10")
is.NoErr(app.Close())
}

func TestAddView(t *testing.T) {
is := is.New(t)
ctx := context.Background()
dir := t.TempDir()
td := testdir.New(dir)
td.Files["controller/controller.go"] = `
package controller
type Controller struct {}
func (c *Controller) Show() (id int) { return 10 }
`
td.NodeModules["svelte"] = versions.Svelte
td.NodeModules["livebud"] = "*"
is.NoErr(td.Write(ctx))
cli := testcli.New(dir)
app, err := cli.Start(ctx, "run")
is.NoErr(err)
defer app.Close()
hot, err := app.Hot("/bud/hot/view/show.svelte")
is.NoErr(err)
defer hot.Close()
res, err := app.Get("/10")
is.NoErr(err)
is.NoErr(res.DiffHeaders(`
HTTP/1.1 200 OK
Content-Type: application/json
`))
is.Equal(res.Body().String(), "10")
// Add the view
td.Files["view/show.svelte"] = `
<script>
export let id = 0
</script>
<h1>{id}</h1>
`
is.NoErr(td.Write(ctx))
// Wait for the app to be ready again
app.Ready(ctx)
// Check that we received a hot reload event
event, err := hot.Next(ctx)
is.NoErr(err)
is.In(string(event.Data), `{"reload":true}`)
// Should change
res, err = app.Get("/10")
is.NoErr(err)
is.NoErr(res.DiffHeaders(`
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: text/html
`))
is.In(res.Body().String(), "<h1>10</h1>")
}
20 changes: 10 additions & 10 deletions internal/cli/run/run.go
Expand Up @@ -210,10 +210,10 @@ func (a *appServer) Run(ctx context.Context) error {
a.bus.Publish("app:ready", nil)
a.log.Debug("run: published event", "event", "app:ready")
// Watch for changes
return watcher.Watch(ctx, a.dir, catchError(a.prompter, func(paths []string) error {
a.log.Debug("run: files changes", "paths", paths)
a.prompter.Reloading(paths)
if canIncrementallyReload(paths) {
return watcher.Watch(ctx, a.dir, catchError(a.prompter, func(events []watcher.Event) error {
a.log.Debug("run: files changes", "paths", events)
a.prompter.Reloading(events)
if canIncrementallyReload(events) {
a.log.Debug("run: incrementally reloading")
// Publish the frontend:update event
a.bus.Publish("frontend:update", nil)
Expand Down Expand Up @@ -258,19 +258,19 @@ func (a *appServer) Run(ctx context.Context) error {

// logWrap wraps the watch function in a handler that logs the error instead of
// returning the error (and canceling the watcher)
func catchError(prompter *prompter.Prompter, fn func(paths []string) error) func(paths []string) error {
return func(paths []string) error {
if err := fn(paths); err != nil {
func catchError(prompter *prompter.Prompter, fn func(events []watcher.Event) error) func(events []watcher.Event) error {
return func(events []watcher.Event) error {
if err := fn(events); err != nil {
prompter.FailReload(err.Error())
}
return nil
}
}

// canIncrementallyReload returns true if we can incrementally reload a page
func canIncrementallyReload(paths []string) bool {
for _, path := range paths {
if filepath.Ext(path) == ".go" {
func canIncrementallyReload(events []watcher.Event) bool {
for _, event := range events {
if event.Op != watcher.OpUpdate || filepath.Ext(event.Path) == ".go" {
return false
}
}
Expand Down
32 changes: 17 additions & 15 deletions internal/prompter/prompter.go
Expand Up @@ -29,6 +29,7 @@ import (
"time"

"github.com/livebud/bud/package/log/console"
"github.com/livebud/bud/package/watcher"
)

// States
Expand All @@ -53,8 +54,8 @@ type Prompter struct {
oldStdErr bytes.Buffer

// Path to changed files
paths []string
oldPaths []string
events []watcher.Event
oldEvents []watcher.Event

// For storing states (fail, success, reload,...)
state string
Expand Down Expand Up @@ -137,19 +138,15 @@ func (p *Prompter) FailReload(err string) {
console.Error(err)
}

func different(paths, oldPaths []string) bool {
if len(paths) != len(oldPaths) {
func different(events, oldEvents []watcher.Event) bool {
if len(events) != len(oldEvents) {
return true
}

sort.Strings(paths)
sort.Strings(oldPaths)
for i := range paths {
if paths[i] != oldPaths[i] {
for i := range events {
if events[i] != oldEvents[i] {
return true
}
}

return false
}

Expand All @@ -170,7 +167,7 @@ func (p *Prompter) SuccessReload() {
}

// Reset counter if user changed working file
if different(p.paths, p.oldPaths) {
if different(p.events, p.oldEvents) {
p.Counter = 1
}

Expand All @@ -185,14 +182,19 @@ func (p *Prompter) SuccessReload() {

// Prompt "Reloading..." message.
// Start timer.
func (p *Prompter) Reloading(paths []string) {
func (p *Prompter) Reloading(events []watcher.Event) {
if err := p.handleState(reload); err != nil {
return
}

// Update paths
p.oldPaths = p.paths
p.paths = paths
// Sort incoming events for faster comparison later between old and new events
sort.Slice(events, func(i, j int) bool {
return events[i].String() < events[j].String()
})

// Update events
p.oldEvents = p.events
p.events = events

// Prevent override
if p.blankStdErr() && p.blankStdOut() && p.oldState != fail {
Expand Down
75 changes: 48 additions & 27 deletions package/watcher/watcher.go
Expand Up @@ -25,40 +25,61 @@ var Stop = errors.New("stop watching")
// this snappy.
var debounceDelay = 20 * time.Millisecond

func newPathSet() *pathSet {
return &pathSet{
paths: map[string]struct{}{},
// Op is the type of file event that occurred
type Op byte

const (
OpCreate Op = 'C'
OpUpdate Op = 'U'
OpDelete Op = 'D'
)

// Event is used to track file events
type Event struct {
Op Op
Path string
}

func (e Event) String() string {
return string(e.Op) + ":" + e.Path
}

func newEventSet() *eventSet {
return &eventSet{
events: map[string]Event{},
}
}

// pathset is used to collect paths that have changed and flush them all at once
// eventset is used to collect events that have changed and flush them all at once
// when the watch function is triggered.
type pathSet struct {
mu sync.RWMutex
paths map[string]struct{}
type eventSet struct {
mu sync.RWMutex
events map[string]Event
}

// Add a path to the set
func (p *pathSet) Add(path string) {
// Add a event to the set
func (p *eventSet) Add(event Event) {
p.mu.Lock()
defer p.mu.Unlock()
p.paths[path] = struct{}{}
p.events[event.String()] = event
}

// Flush the stored paths and clear the path set.
func (p *pathSet) Flush() (paths []string) {
// Flush the stored events and clear the event set.
func (p *eventSet) Flush() (events []Event) {
p.mu.Lock()
defer p.mu.Unlock()
for path := range p.paths {
paths = append(paths, path)
for _, event := range p.events {
events = append(events, event)
}
sort.Strings(paths)
p.paths = map[string]struct{}{}
return paths
sort.Slice(events, func(i, j int) bool {
return events[i].String() < events[j].String()
})
p.events = map[string]Event{}
return events
}

// Watch function
func Watch(ctx context.Context, dir string, fn func(paths []string) error) error {
func Watch(ctx context.Context, dir string, fn func(events []Event) error) error {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return err
Expand All @@ -68,12 +89,12 @@ func Watch(ctx context.Context, dir string, fn func(paths []string) error) error
gitIgnore := gitignore.From(dir)
// Trigger is debounced to group events together
errorCh := make(chan error)
pathset := newPathSet()
eventSet := newEventSet()
debounce := debounce.New(debounceDelay)
trigger := func(path string) {
pathset.Add(path)
trigger := func(event Event) {
eventSet.Add(event)
debounce(func() {
if err := fn(pathset.Flush()); err != nil {
if err := fn(eventSet.Flush()); err != nil {
errorCh <- err
}
})
Expand Down Expand Up @@ -108,15 +129,15 @@ func Watch(ctx context.Context, dir string, fn func(paths []string) error) error
// Remove the path and emit an update
watcher.Remove(path)
// Trigger an update
trigger(path)
trigger(Event{OpDelete, path})
return nil
}
// Remove the file or directory from the watcher.
// We intentionally ignore errors for this case.
remove := func(path string) error {
watcher.Remove(path)
// Trigger an update
trigger(path)
trigger(Event{OpDelete, path})
return nil
}
// Watching a file or directory as long as it's not inside .gitignore.
Expand Down Expand Up @@ -147,6 +168,7 @@ func Watch(ctx context.Context, dir string, fn func(paths []string) error) error
// If it's a directory, walk the dir and trigger creates
// because those create events won't happen on their own
if stat.IsDir() {
trigger(Event{OpCreate, path})
des, err := os.ReadDir(path)
if err != nil {
return err
Expand All @@ -156,11 +178,10 @@ func Watch(ctx context.Context, dir string, fn func(paths []string) error) error
return err
}
}
trigger(path)
return nil
}
// Otherwise, trigger the create
trigger(path)
trigger(Event{OpCreate, path})
return nil
}
// A file or directory has been updated. Notify our matchers.
Expand All @@ -181,7 +202,7 @@ func Watch(ctx context.Context, dir string, fn func(paths []string) error) error
return nil
}
// Trigger an update
trigger(path)
trigger(Event{OpUpdate, path})
return nil
}

Expand Down

0 comments on commit 4ec496d

Please sign in to comment.