From 1d521251a2ef3e1f11f4e8836e52bbb7ecf17522 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sun, 28 Jun 2026 22:53:27 +0800 Subject: [PATCH] feat: watch vault file changes --- internal/api/app.go | 35 +++- internal/api/app_test.go | 58 ++++++ internal/core/filewatcher/service.go | 233 ++++++++++++++++++++++ internal/core/filewatcher/service_test.go | 86 ++++++++ 4 files changed, 410 insertions(+), 2 deletions(-) create mode 100644 internal/core/filewatcher/service.go create mode 100644 internal/core/filewatcher/service_test.go diff --git a/internal/api/app.go b/internal/api/app.go index 82f5043..31f22da 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -20,6 +20,7 @@ import ( "github.com/verstak/verstak-desktop/internal/core/events" "github.com/verstak/verstak-desktop/internal/core/externalopen" corefiles "github.com/verstak/verstak-desktop/internal/core/files" + "github.com/verstak/verstak-desktop/internal/core/filewatcher" "github.com/verstak/verstak-desktop/internal/core/permissions" "github.com/verstak/verstak-desktop/internal/core/plugin" "github.com/verstak/verstak-desktop/internal/core/pluginstate" @@ -60,6 +61,7 @@ type App struct { workbench *coreworkbench.Router workspace *workspace.Manager syncSvc *syncsvc.Service + fileWatcher *filewatcher.Service debug bool activityEvents map[string]bool } @@ -100,10 +102,12 @@ func NewApp( workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)), workspace: workspaceMgr, syncSvc: syncService, + fileWatcher: filewatcher.NewService(bus, 0), debug: debugEnabled, activityEvents: make(map[string]bool), } app.ensureActivityProviderSubscriptions() + app.startFileWatcherForOpenVault() return app } @@ -699,7 +703,11 @@ func (a *App) CreateVault(path string) error { if a.vault == nil { return fmt.Errorf("vault service not initialized") } - return a.vault.CreateVault(path) + if err := a.vault.CreateVault(path); err != nil { + return err + } + a.startFileWatcherForOpenVault() + return nil } // OpenVault opens an existing vault at the given path. @@ -707,7 +715,11 @@ func (a *App) OpenVault(path string) error { if a.vault == nil { return fmt.Errorf("vault service not initialized") } - return a.vault.OpenVault(path) + if err := a.vault.OpenVault(path); err != nil { + return err + } + a.startFileWatcherForOpenVault() + return nil } // CloseVault closes the current vault. @@ -715,6 +727,9 @@ func (a *App) CloseVault() error { if a.vault == nil { return fmt.Errorf("vault service not initialized") } + if a.fileWatcher != nil { + a.fileWatcher.Stop() + } a.vault.CloseVault() return nil } @@ -1408,9 +1423,25 @@ func (a *App) SetCurrentVault(path string) string { log.Printf("[api] SetCurrentVault: failed to register workspace capability: %v", err) } } + a.startFileWatcherForOpenVault() return "" } +func (a *App) startFileWatcherForOpenVault() { + if a == nil || a.vault == nil || a.eventBus == nil { + return + } + if a.vault.GetVaultStatus() != vault.StatusOpen { + return + } + if a.fileWatcher == nil { + a.fileWatcher = filewatcher.NewService(a.eventBus, 0) + } + if err := a.fileWatcher.Start(a.vault.GetVaultPath()); err != nil { + log.Printf("[api] file watcher start failed: %v", err) + } +} + // ─── Workspace API ───────────────────────────────────────── // ListWorkspaces returns top-level physical workspace folders. diff --git a/internal/api/app_test.go b/internal/api/app_test.go index bb3b57d..3831d3a 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/verstak/verstak-desktop/internal/core/appsettings" "github.com/verstak/verstak-desktop/internal/core/capability" @@ -1067,6 +1068,63 @@ func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) { } } +func TestSetCurrentVaultStartsLiveFileWatcher(t *testing.T) { + tmpDir := t.TempDir() + vaultParent := filepath.Join(tmpDir, "vault-parent") + if err := os.MkdirAll(vaultParent, 0o755); err != nil { + t.Fatal(err) + } + + bus := events.NewBus() + vaultService := vault.NewVault(bus) + if err := vaultService.CreateVault(vaultParent); err != nil { + t.Fatalf("CreateVault: %v", err) + } + vaultService.CloseVault() + + settings := appsettings.NewManager(filepath.Join(tmpDir, "config.json")) + if err := settings.Load(); err != nil { + t.Fatalf("settings Load: %v", err) + } + + received := make(chan events.Event, 4) + bus.Subscribe("file.changed", func(event events.Event) { + received <- event + }) + + app := &App{ + capRegistry: capability.NewRegistry(), + eventBus: bus, + vault: vaultService, + appSettings: settings, + } + if errStr := app.SetCurrentVault(vaultParent); errStr != "" { + t.Fatalf("SetCurrentVault: %s", errStr) + } + t.Cleanup(func() { + if app.fileWatcher != nil { + app.fileWatcher.Stop() + } + }) + + if err := os.WriteFile(filepath.Join(vaultService.GetVaultPath(), "external.md"), []byte("changed"), 0o644); err != nil { + t.Fatal(err) + } + + select { + case event := <-received: + payload, ok := event.Payload.(map[string]interface{}) + if !ok { + t.Fatalf("payload type = %T, want map", event.Payload) + } + if payload["path"] != "external.md" || payload["operation"] != "external.create" { + t.Fatalf("event payload = %#v", payload) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for watcher event") + } +} + func TestWorkspaceAPIUsesTopLevelFoldersAndMetadataSnapshot(t *testing.T) { app, vaultDir := newFilesTestApp(t, []string{"files.read"}) app.workspace = workspace.NewManager(vaultDir) diff --git a/internal/core/filewatcher/service.go b/internal/core/filewatcher/service.go new file mode 100644 index 0000000..97cbf1d --- /dev/null +++ b/internal/core/filewatcher/service.go @@ -0,0 +1,233 @@ +// 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 +} diff --git a/internal/core/filewatcher/service_test.go b/internal/core/filewatcher/service_test.go new file mode 100644 index 0000000..dc7c51f --- /dev/null +++ b/internal/core/filewatcher/service_test.go @@ -0,0 +1,86 @@ +package filewatcher + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/verstak/verstak-desktop/internal/core/events" +) + +func TestServicePublishesExternalFileChanges(t *testing.T) { + root := t.TempDir() + bus := events.NewBus() + received := make(chan events.Event, 4) + bus.Subscribe("file.changed", func(event events.Event) { + received <- event + }) + + service := NewService(bus, 10*time.Millisecond) + if err := service.Start(root); err != nil { + t.Fatalf("Start: %v", err) + } + t.Cleanup(service.Stop) + + if err := os.WriteFile(filepath.Join(root, "note.md"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + + event := waitForEvent(t, received) + payload, ok := event.Payload.(map[string]interface{}) + if !ok { + t.Fatalf("payload type = %T, want map", event.Payload) + } + if event.Name != "file.changed" { + t.Fatalf("event name = %q, want file.changed", event.Name) + } + if payload["path"] != "note.md" { + t.Fatalf("path = %v, want note.md", payload["path"]) + } + if payload["operation"] != "external.create" { + t.Fatalf("operation = %v, want external.create", payload["operation"]) + } + if payload["type"] != "file" { + t.Fatalf("type = %v, want file", payload["type"]) + } +} + +func TestServiceIgnoresReservedVerstakPaths(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".verstak"), 0o755); err != nil { + t.Fatal(err) + } + bus := events.NewBus() + received := make(chan events.Event, 4) + bus.Subscribe("file.changed", func(event events.Event) { + received <- event + }) + + service := NewService(bus, 10*time.Millisecond) + if err := service.Start(root); err != nil { + t.Fatalf("Start: %v", err) + } + t.Cleanup(service.Stop) + + if err := os.WriteFile(filepath.Join(root, ".verstak", "internal.json"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + + select { + case event := <-received: + t.Fatalf("unexpected reserved-path event: %+v", event) + case <-time.After(80 * time.Millisecond): + } +} + +func waitForEvent(t *testing.T, eventCh <-chan events.Event) events.Event { + t.Helper() + select { + case event := <-eventCh: + return event + case <-time.After(500 * time.Millisecond): + t.Fatal("timed out waiting for file.changed") + return events.Event{} + } +}