feat: watch vault file changes
This commit is contained in:
parent
5a74809ab7
commit
1d521251a2
|
|
@ -20,6 +20,7 @@ import (
|
||||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
"github.com/verstak/verstak-desktop/internal/core/events"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/externalopen"
|
"github.com/verstak/verstak-desktop/internal/core/externalopen"
|
||||||
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
|
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/permissions"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
||||||
|
|
@ -60,6 +61,7 @@ type App struct {
|
||||||
workbench *coreworkbench.Router
|
workbench *coreworkbench.Router
|
||||||
workspace *workspace.Manager
|
workspace *workspace.Manager
|
||||||
syncSvc *syncsvc.Service
|
syncSvc *syncsvc.Service
|
||||||
|
fileWatcher *filewatcher.Service
|
||||||
debug bool
|
debug bool
|
||||||
activityEvents map[string]bool
|
activityEvents map[string]bool
|
||||||
}
|
}
|
||||||
|
|
@ -100,10 +102,12 @@ func NewApp(
|
||||||
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
|
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
|
||||||
workspace: workspaceMgr,
|
workspace: workspaceMgr,
|
||||||
syncSvc: syncService,
|
syncSvc: syncService,
|
||||||
|
fileWatcher: filewatcher.NewService(bus, 0),
|
||||||
debug: debugEnabled,
|
debug: debugEnabled,
|
||||||
activityEvents: make(map[string]bool),
|
activityEvents: make(map[string]bool),
|
||||||
}
|
}
|
||||||
app.ensureActivityProviderSubscriptions()
|
app.ensureActivityProviderSubscriptions()
|
||||||
|
app.startFileWatcherForOpenVault()
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -699,7 +703,11 @@ func (a *App) CreateVault(path string) error {
|
||||||
if a.vault == nil {
|
if a.vault == nil {
|
||||||
return fmt.Errorf("vault service not initialized")
|
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.
|
// OpenVault opens an existing vault at the given path.
|
||||||
|
|
@ -707,7 +715,11 @@ func (a *App) OpenVault(path string) error {
|
||||||
if a.vault == nil {
|
if a.vault == nil {
|
||||||
return fmt.Errorf("vault service not initialized")
|
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.
|
// CloseVault closes the current vault.
|
||||||
|
|
@ -715,6 +727,9 @@ func (a *App) CloseVault() error {
|
||||||
if a.vault == nil {
|
if a.vault == nil {
|
||||||
return fmt.Errorf("vault service not initialized")
|
return fmt.Errorf("vault service not initialized")
|
||||||
}
|
}
|
||||||
|
if a.fileWatcher != nil {
|
||||||
|
a.fileWatcher.Stop()
|
||||||
|
}
|
||||||
a.vault.CloseVault()
|
a.vault.CloseVault()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -1408,9 +1423,25 @@ func (a *App) SetCurrentVault(path string) string {
|
||||||
log.Printf("[api] SetCurrentVault: failed to register workspace capability: %v", err)
|
log.Printf("[api] SetCurrentVault: failed to register workspace capability: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
a.startFileWatcherForOpenVault()
|
||||||
return ""
|
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 ─────────────────────────────────────────
|
// ─── Workspace API ─────────────────────────────────────────
|
||||||
|
|
||||||
// ListWorkspaces returns top-level physical workspace folders.
|
// ListWorkspaces returns top-level physical workspace folders.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/verstak/verstak-desktop/internal/core/appsettings"
|
"github.com/verstak/verstak-desktop/internal/core/appsettings"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/capability"
|
"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) {
|
func TestWorkspaceAPIUsesTopLevelFoldersAndMetadataSnapshot(t *testing.T) {
|
||||||
app, vaultDir := newFilesTestApp(t, []string{"files.read"})
|
app, vaultDir := newFilesTestApp(t, []string{"files.read"})
|
||||||
app.workspace = workspace.NewManager(vaultDir)
|
app.workspace = workspace.NewManager(vaultDir)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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{}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue