MyGo
Watch

Official plugins

Watch

Changes to files, in the directories the app lets pages watch.

The watch plugin lets pages follow changes to the directories and files the app lets them watch, such as a project open in an editor. Go code and native UI don't need it: they call mygo.WatchFiles directly (see Watching files), which the plugin uses too.

Set up

Use the plugin in Go with the directories and files pages may watch, each under a name:

import (
	"github.com/egoist/mygo"
	"github.com/egoist/mygo/plugins/watch"
)

func main() {
	mygo.Use(watch.New(watch.Options{
		Roots: map[string]watch.Root{
			"project": {Path: projectDir, Recursive: true},
			"settings": {Path: settingsFile},
		},
	}))
	// ...
}

and add its package to the frontend:

bun add @mygo-plugins/watch

Pages name a root by its name, never by a path, so they can't watch anything else. A name is lowercase letters, digits and dashes, starting with a letter. A path must be absolute and a directory or a file when mygo.Use runs, which panics otherwise; it resolves the path's symlinks, as macOS's /tmp is one. watch.Plugin, without roots, lets pages watch nothing.

Recursive lets pages watch the directories in the directory too, and what is in them; without it they can watch the directory itself and the files in it. Ignore leaves out files and directories, by their path relative to the root, as mygo.FileWatchOptions.Ignore does: a directory it ignores, as node_modules, is never opened, and doesn't count in the entries a watch may cover (MaxEntries: 8192, or 65536 on macOS).

"project": {Path: projectDir, Recursive: true, Ignore: func(p string, isDir bool) bool {
	return isDir && (path.Base(p) == "node_modules" || path.Base(p) == ".git")
}},

Roots can also be granted while the app runs, such as a folder the user chose: Resolve is asked for the root of a name that is not in Roots, with the call's context, and returns false for names it doesn't know.

var granted sync.Map // name → watch.Root

watch.New(watch.Options{
	Resolve: func(ctx context.Context, name string) (watch.Root, bool) {
		root, ok := granted.Load(name)
		if !ok {
			return watch.Root{}, false
		}
		return root.(watch.Root), true
	},
})

// After the user picked dir in a dialog, a bound method returns the name
// the page then watches.
granted.Store("picked-1", watch.Root{Path: dir, Recursive: true})

Allow, when set, is asked before each watch, with the call's context (mygo.CallerWindow(ctx) is the window asking) and the watch.Request: the root's name, the path in it and whether it is recursive. It can refuse it:

watch.New(watch.Options{
	Roots: roots,
	Allow: func(ctx context.Context, r watch.Request) bool {
		return mygo.CallerWindow(ctx) == editorWindow
	},
})

Each page can watch 8 directories and files at a time (MaxWatchesPerPage), and all pages 128 (MaxWatches).

Watching

watch starts a watch and resolves once everything is watched. Iterate it for the changes, a batch of events each time they stop:

import { watch } from "@mygo-plugins/watch";

const watcher = await watch("project", { recursive: true });
for await (const events of watcher) {
  for (const event of events) console.log(event.op, event.path, event.oldPath);
}

path watches a directory or a file in the root rather than the root itself, relative to it and separated by slashes ("src", "package.json"); it can't leave the root, nor go through a symlink.

An event has op, one of create, write, remove and rename, path, relative to what is watched and separated by slashes (. is what is watched itself), oldPath, where a rename moved from, and isDir. What is already there when the watch starts is not reported. A file watched is watched through its directory: a file replaced, as editors save, is a write of ., one removed a remove, and one that appears again a create. When what is watched, or the directory of a file watched, is removed, renamed or replaced, the last batch has a remove of ., and the iteration ends.

Changes wait for 50 ms of quiet before they are reported, and never more than 500 ms while more keep coming; debounceMs sets the quiet, as Go's Debounce: 0 is 50 ms, and a negative number reports them right away. A watch compares what it sees with what it saw, so events say what is different, not every step that made it so: a file written many times in a row is one write, and one created and removed in between is nothing. Treat them as hints to read the files again. Go doesn't look again until the page has taken a batch, so nothing is lost while the page is busy.

watcher.close(), break out of the loop, or an AbortSignal passed as signal stop the watch, and so does the page navigating or closing. watcher.closed settles once Go has stopped it, and rejects with the error that stopped it. Errors are WatchErrors, whose code is:

  • denied: the name is not one of the roots, Allow refused, the path is not in the root, or the page asked for a recursive watch, or one of a directory in it, of a root without Recursive;
  • not-found: the path doesn't exist;
  • limit: the page or the app watch too many, or the directory has more than MaxEntries files and directories (8192, or 65536 on macOS);
  • unsupported: the platform can't watch files;
  • path-encoding: a file's name is not valid UTF-8, which pages can't be given;
  • io: anything else, such as a root replaced since the app started.

Platforms

Watches follow what is in the directory, not where symlinks and Windows junctions in it point. Only expose directories the app controls: names keep pages from choosing paths, but another program can still move or replace what is in a directory.

  • macOS watches with FSEvents: a watch is one event stream covering everything beneath its root, so it costs no file descriptor per file, and a file watched is covered by the stream of its directory. A write reports itself only when FSEvents says the content changed, since APFS also calls an O_TRUNC write a creation; the watch's comparison reports the rest.
  • Linux watches each directory with inotify, through one instance for the app, whose limit is the system's (fs.inotify.max_user_watches).
  • Windows watches a recursive directory with one ReadDirectoryChangesW, so the directories in it can be renamed, and also watches its parent to know when it is renamed. While a directory is watched, Windows doesn't let its parent and the directories above be renamed, as for every program that watches files. The root of a drive can be watched, and folders of cloud files, as OneDrive's.