Files
kitty/tools/watch/api.go

257 lines
8.1 KiB
Go

package watch
import (
"bufio"
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/sgtdi/fswatcher"
"golang.org/x/sys/unix"
"github.com/kovidgoyal/kitty/tools/cli"
"github.com/kovidgoyal/kitty/tools/config"
"github.com/kovidgoyal/kitty/tools/utils"
)
var _ = fmt.Print
// get_parent_dirs returns a deduplicated list of the immediate parent directory for each path.
// Unlike get_unique_directories it does not filter out subdirectories, so every unique
// parent is returned even when some are descendants of others. This is the correct
// set of directories to pass to a non-recursive (top-level) file-system watcher.
func get_parent_dirs(paths []string) []string {
dirSet := utils.NewSet[string](len(paths))
for _, p := range paths {
dirSet.Add(filepath.Dir(p))
}
return dirSet.AsSlice()
}
// returns the closest unique parent directories for a list of paths.
// It excludes any directory that is a subdirectory of another directory already in the result set.
func get_unique_directories(paths []string) []string {
if len(paths) == 0 {
return nil
}
// 1. Extract parent directories and remove duplicates
dirMap := utils.NewSet[string](len(paths))
for _, p := range paths {
dirMap.Add(filepath.Dir(p))
}
// 2. Convert map to a sorted slice
// Sorting ensures that shorter paths (potential parents) come before longer ones (potential children)
uniqueDirs := dirMap.AsSlice()
sort.Strings(uniqueDirs)
// 3. Filter out subdirectories
var result []string
for _, current := range uniqueDirs {
isSubDir := false
for _, parent := range result {
// Check if 'current' is a subdirectory of 'parent'
// Strings.HasPrefix is safe here because paths are sorted and Cleaned by filepath.Dir
if current == parent || strings.HasPrefix(current, parent+string(filepath.Separator)) {
isSubDir = true
break
}
}
if !isSubDir {
result = append(result, current)
}
}
return result
}
// resolve_path resolves symlinks in the directory component of path so the result
// matches what the OS-level file-system watcher (FSEvents on macOS, inotify on Linux)
// reports. This is important on macOS where /tmp is a symlink to /private/tmp.
// If the directory cannot be resolved (e.g. it doesn't exist yet) the path is
// returned cleaned but unresolved.
func resolve_path(path string) string {
dir := filepath.Dir(path)
if resolved, err := filepath.EvalSymlinks(dir); err == nil {
return filepath.Join(resolved, filepath.Base(path))
}
return filepath.Clean(path)
}
func safe_eval_symlinks(path string) string {
if q, err := filepath.EvalSymlinks(path); err == nil {
path = q
}
return path
}
func get_set_of_config_files(config_paths []string) *utils.Set[string] {
cp := config.ConfigParser{
AllIncludedFiles: utils.NewSet[string](), LineHandler: func(k, v string) error { return nil }}
config_paths = utils.Filter(config_paths, func(path string) bool {
_, err := os.Stat(path)
return err == nil
})
cp.ParseFiles(config_paths...)
// Resolve symlinks in all paths collected by the parser (important on macOS
// where /tmp -> /private/tmp causes mismatches with FSEvents-reported paths).
result := utils.NewSet[string](cp.AllIncludedFiles.Len() + len(config_paths)*4)
for _, p := range cp.AllIncludedFiles.AsSlice() {
result.Add(safe_eval_symlinks(resolve_path(p)))
}
for _, path := range config_paths {
path = resolve_path(path)
dir := filepath.Dir(path)
result.Add(safe_eval_symlinks(path))
for _, q := range []string{"dark-theme.auto.conf", "light-theme.auto.conf", "no-preference-theme.auto.conf"} {
result.Add(safe_eval_symlinks(resolve_path(filepath.Join(dir, q))))
}
}
return result
}
// watch_for_config_changes watches the parent directories of every conf file (main configs,
// includes, and auto color-scheme files) and calls action whenever one of those files changes.
// Watching is non-recursive (top-level only): only the immediate parent directories are added.
// When a conf file change is detected the full set of conf files is re-scanned so that newly
// added or removed include directives are reflected in the watched-directory set.
// It runs until ctx is cancelled.
func watch_for_config_changes(ctx context.Context, action func() error, debounce_time time.Duration, config_paths []string) error {
event_chan := make(chan fswatcher.WatchEvent)
all_paths := get_set_of_config_files(config_paths)
// desired_dirs is the full set of parent directories we want to watch
// (one per conf file, including files that may not yet exist).
desired_dirs := utils.NewSet[string]()
for _, p := range all_paths.AsSlice() {
desired_dirs.Add(filepath.Dir(p))
}
if desired_dirs.Len() == 0 {
return fmt.Errorf("No directories to watch provided")
}
// Create the watcher with top-level (non-recursive) depth.
opts := []fswatcher.WatcherOpt{fswatcher.WithCooldown(debounce_time)}
watched_dirs := utils.NewSet[string]()
for _, dir := range desired_dirs.AsSlice() {
if unix.Access(dir, unix.R_OK|unix.X_OK) == nil {
opts = append(opts, fswatcher.WithPath(dir, fswatcher.WithDepth(fswatcher.WatchTopLevel)))
watched_dirs.Add(dir)
}
}
if watched_dirs.Len() == 0 {
return fmt.Errorf("No directories to watch provided")
}
w, err := fswatcher.New(opts...)
if err != nil {
return err
}
go w.Watch(ctx)
go func() {
for event := range w.Events() {
event_chan <- event
}
}()
// sync_watched_dirs reconciles watched_dirs with desired_dirs: any desired directory
// that now exists is added to the watcher, and any watched directory that is no longer
// desired is dropped.
sync_watched_dirs := func() {
desired_dirs.ForEach(func(d string) {
if !watched_dirs.Has(d) && unix.Access(d, unix.R_OK|unix.X_OK) == nil {
if err := w.AddPath(d, fswatcher.WithDepth(fswatcher.WatchTopLevel)); err == nil {
watched_dirs.Add(d)
}
}
})
watched_dirs.ForEach(func(d string) {
if !desired_dirs.Has(d) {
_ = w.DropPath(d)
watched_dirs.Discard(d)
}
})
}
for {
select {
case event := <-event_chan:
// On every event try to activate any desired directories that may have been
// created since the last check (e.g. a new include directory was mkdir'd).
sync_watched_dirs()
new_all_paths := get_set_of_config_files(config_paths)
if new_all_paths.Has(resolve_path(event.Path)) {
// A conf file changed: rebuild desired_dirs from the new include set and
// sync the watcher so new include directories are watched and stale ones dropped.
new_desired := utils.NewSet[string]()
for _, p := range new_all_paths.AsSlice() {
new_desired.Add(filepath.Dir(p))
}
desired_dirs = new_desired
sync_watched_dirs()
if err := action(); err != nil {
fmt.Fprintf(os.Stderr, "failed to signal kitty in event: %s with error: %s\n", event, err)
}
}
case <-ctx.Done():
return nil
}
}
}
func watch_for_kitty_config_changes(action func() error, debounce_time time.Duration, config_paths []string) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
}
_ = scanner.Err()
cancel()
}()
return watch_for_config_changes(ctx, action, debounce_time, config_paths)
}
func signal_kitty_to_reload_config(kitty_pid int) error {
return unix.Kill(kitty_pid, unix.SIGUSR1)
}
func EntryPoint(root *cli.Command) {
root.AddSubCommand(&cli.Command{
Name: "__watch_conf__",
Hidden: true,
OnlyArgsAllowed: true,
Run: func(cmd *cli.Command, args []string) (rc int, err error) {
if len(args) < 3 {
return 1, fmt.Errorf("Usage: __watch_conf__ kitty_pid debounce_time_ms config_paths...")
}
kitty_pid, err := strconv.Atoi(args[0])
if err != nil {
return 1, err
}
debounce_time_ms, err := strconv.Atoi(args[1])
if err != nil {
return 1, err
}
if debounce_time_ms < 0 {
return 0, fmt.Errorf("debounce_time must be >= 0")
}
config_paths := utils.Map(resolve_path, args[2:])
if err = watch_for_kitty_config_changes(
func() error { return signal_kitty_to_reload_config(kitty_pid) },
time.Millisecond*time.Duration(debounce_time_ms), config_paths); err != nil {
return 1, err
}
return 0, nil
},
})
}