verstak-desktop/internal/core/filewatcher/service.go

234 lines
4.6 KiB
Go

// Package filewatcher provides a lightweight live vault change watcher.
package filewatcher
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/verstak/verstak-desktop/internal/core/events"
)
const defaultInterval = 750 * time.Millisecond
type entryKind string
const (
entryFile entryKind = "file"
entryFolder entryKind = "folder"
entrySymlink entryKind = "symlink"
entryUnknown entryKind = "unknown"
)
type snapshotEntry struct {
kind entryKind
size int64
modTime time.Time
}
// Service polls an open vault and publishes file.changed events for external changes.
type Service struct {
bus *events.Bus
interval time.Duration
mu sync.Mutex
root string
cancel chan struct{}
done chan struct{}
current map[string]snapshotEntry
}
// NewService creates a watcher. The interval parameter is mainly for tests.
func NewService(bus *events.Bus, interval time.Duration) *Service {
if interval <= 0 {
interval = defaultInterval
}
return &Service{bus: bus, interval: interval}
}
// Start begins watching root. Any previous watch is stopped first.
func (s *Service) Start(root string) error {
if s == nil {
return fmt.Errorf("file watcher is nil")
}
if root == "" {
return fmt.Errorf("file watcher root is empty")
}
root, err := filepath.Abs(root)
if err != nil {
return err
}
info, err := os.Stat(root)
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("file watcher root is not a directory: %s", root)
}
initial, err := scan(root)
if err != nil {
return err
}
s.Stop()
s.mu.Lock()
s.root = root
s.current = initial
s.cancel = make(chan struct{})
s.done = make(chan struct{})
cancel := s.cancel
done := s.done
s.mu.Unlock()
go s.loop(root, cancel, done)
return nil
}
// Stop stops the active watcher.
func (s *Service) Stop() {
if s == nil {
return
}
s.mu.Lock()
cancel := s.cancel
done := s.done
if cancel == nil {
s.mu.Unlock()
return
}
s.cancel = nil
s.done = nil
s.root = ""
s.current = nil
close(cancel)
s.mu.Unlock()
<-done
}
func (s *Service) loop(root string, cancel <-chan struct{}, done chan<- struct{}) {
defer close(done)
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.poll(root)
case <-cancel:
return
}
}
}
func (s *Service) poll(root string) {
next, err := scan(root)
if err != nil {
return
}
s.mu.Lock()
prev := s.current
s.current = next
s.mu.Unlock()
for path, entry := range next {
old, ok := prev[path]
if !ok {
s.publish(path, "external.create", entry.kind)
continue
}
if entry.kind == entryFile && (entry.size != old.size || !entry.modTime.Equal(old.modTime)) {
s.publish(path, "external.update", entry.kind)
}
}
for path, entry := range prev {
if _, ok := next[path]; !ok {
s.publish(path, "external.delete", entry.kind)
}
}
}
func (s *Service) publish(path, operation string, kind entryKind) {
if s.bus == nil {
return
}
s.bus.Publish(events.Event{
Name: "file.changed",
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Payload: map[string]interface{}{
"path": path,
"title": path,
"operation": operation,
"type": string(kind),
"workspaceRootPath": workspaceRoot(path),
"external": true,
},
})
}
func scan(root string) (map[string]snapshotEntry, error) {
out := make(map[string]snapshotEntry)
err := filepath.WalkDir(root, func(path string, dirEntry fs.DirEntry, err error) error {
if err != nil {
return nil
}
if path == root {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return nil
}
rel = filepath.ToSlash(rel)
if isReserved(rel) {
if dirEntry.IsDir() {
return filepath.SkipDir
}
return nil
}
info, err := dirEntry.Info()
if err != nil {
return nil
}
out[rel] = snapshotEntry{
kind: kindFromInfo(info),
size: info.Size(),
modTime: info.ModTime(),
}
return nil
})
return out, err
}
func kindFromInfo(info fs.FileInfo) entryKind {
if info.Mode()&os.ModeSymlink != 0 {
return entrySymlink
}
if info.IsDir() {
return entryFolder
}
if info.Mode().IsRegular() {
return entryFile
}
return entryUnknown
}
func isReserved(rel string) bool {
first := strings.Split(filepath.ToSlash(rel), "/")[0]
return strings.EqualFold(first, ".verstak")
}
func workspaceRoot(path string) string {
path = strings.Trim(strings.TrimSpace(filepath.ToSlash(path)), "/")
if path == "" {
return ""
}
if idx := strings.Index(path, "/"); idx >= 0 {
return path[:idx]
}
return path
}