esync

Directory watching and remote syncing
Log | Files | Refs | README | LICENSE

commit a846e7ae30e53c926171ab61c6e2db6d3d190854
parent 7e757cf7f829517cd9a8cea9670480f544edec0a
Author: Erik Loualiche <eloualiche@users.noreply.github.com>
Date:   Wed, 18 Mar 2026 21:28:46 -0400

Merge pull request #12 from LouLouLibs/feat/edit-config

feat: edit/create .esync.toml config from TUI
Diffstat:
Resync.toml.example -> .esync.toml.example | 0
M.gitignore | 2+-
MREADME.md | 12++++++------
Mcmd/init.go | 6+++---
Mcmd/sync.go | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Rdemo/esync.toml -> demo/.esync.toml | 0
Mdemo/demo.tape | 2+-
Adocs/superpowers/plans/2026-03-18-edit-config.md | 878+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/superpowers/specs/2026-03-18-edit-config-design.md | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/config/config.go | 37++++++++++++++++++++++++++++++++++++-
Minternal/config/config_test.go | 45+++++++++++++++++++++++++++++++++++++++++++++
Minternal/tui/app.go | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Minternal/tui/dashboard.go | 3+++
Minternal/watcher/watcher.go | 11+++++++++++
14 files changed, 1352 insertions(+), 34 deletions(-)

diff --git a/esync.toml.example b/.esync.toml.example diff --git a/.gitignore b/.gitignore @@ -20,6 +20,6 @@ esync # temporary ignores test-sync tests -/esync.toml +/.esync.toml sync.log .worktrees/ diff --git a/README.md b/README.md @@ -72,7 +72,7 @@ When both `-l` and `-r` are provided, esync runs without a config file (quick mo ### `esync init` -Generate an `esync.toml` configuration file in the current directory. Inspects the project for `.gitignore` patterns and common directories (`.venv`, `build`, `__pycache__`, etc.) to auto-populate ignore rules. +Generate an `.esync.toml` configuration file in the current directory. Inspects the project for `.gitignore` patterns and common directories (`.venv`, `build`, `__pycache__`, etc.) to auto-populate ignore rules. ```bash esync init # interactive prompt for remote @@ -83,7 +83,7 @@ esync init -c ~/.config/esync/config.toml -r server:/data # custom path | Flag | Short | Description | |------------|-------|------------------------------------| | `--remote` | `-r` | Pre-fill remote destination | -| `--config` | `-c` | Output file path (default: `./esync.toml`) | +| `--config` | `-c` | Output file path (default: `./.esync.toml`) | ### `esync check` @@ -127,7 +127,7 @@ esync status esync uses TOML configuration files. The config file is searched in this order: 1. Path given via `-c` / `--config` flag -2. `./esync.toml` (current directory) +2. `./.esync.toml` (current directory) 3. `~/.config/esync/config.toml` 4. `/etc/esync/config.toml` @@ -477,7 +477,7 @@ esync sync -l ./src -r /backup/src Sync to a remote server using a config file: ```toml -# esync.toml +# .esync.toml [sync] local = "." remote = "deploy@prod.example.com:/var/www/mysite" @@ -505,7 +505,7 @@ This uses sensible defaults: archive mode, compression, 500ms debounce, and igno Run in the background with structured logging: ```toml -# esync.toml +# .esync.toml [sync] local = "/home/user/code" remote = "server:/opt/code" @@ -529,7 +529,7 @@ esync sync --daemon -v Keep the remote directory in exact sync by deleting files that no longer exist locally: ```toml -# esync.toml +# .esync.toml [sync] local = "./dist" remote = "cdn-server:/var/www/static" diff --git a/cmd/init.go b/cmd/init.go @@ -49,8 +49,8 @@ var initRemote string var initCmd = &cobra.Command{ Use: "init", - Short: "Generate an esync.toml configuration file", - Long: "Inspect the current directory to generate a smart esync.toml with .gitignore import and common directory exclusion.", + Short: "Generate an .esync.toml configuration file", + Long: "Inspect the current directory to generate a smart .esync.toml with .gitignore import and common directory exclusion.", RunE: runInit, } @@ -67,7 +67,7 @@ func runInit(cmd *cobra.Command, args []string) error { // 1. Determine output path outPath := cfgFile if outPath == "" { - outPath = "./esync.toml" + outPath = "./.esync.toml" } // 2. If file exists, prompt for overwrite confirmation diff --git a/cmd/sync.go b/cmd/sync.go @@ -8,6 +8,7 @@ import ( "path/filepath" "regexp" "strings" + "sync" "syscall" "time" @@ -160,17 +161,26 @@ func runSync(cmd *cobra.Command, args []string) error { // TUI mode // --------------------------------------------------------------------------- -func runTUI(cfg *config.Config, s *syncer.Syncer) error { +// watchState holds the watcher and syncer that can be torn down and rebuilt. +type watchState struct { + watcher *watcher.Watcher + cancel context.CancelFunc + inflight sync.WaitGroup +} + +// startWatching creates a syncer, watcher, and sync handler from the given config. +func startWatching(cfg *config.Config, syncCh chan<- tui.SyncEvent, logCh chan<- tui.LogEntry) (*watchState, error) { ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - app := tui.NewApp(cfg.Sync.Local, cfg.Sync.Remote) - syncCh := app.SyncEventChan() + s := syncer.New(cfg) + s.DryRun = dryRun - logCh := app.LogEntryChan() + ws := &watchState{cancel: cancel} handler := func() { - // Update header status to syncing + ws.inflight.Add(1) + defer ws.inflight.Done() + syncCh <- tui.SyncEvent{Status: "status:syncing"} var lastPct string @@ -179,12 +189,10 @@ func runTUI(cfg *config.Config, s *syncer.Syncer) error { if trimmed == "" { return } - // Stream to log view select { case logCh <- tui.LogEntry{Time: time.Now(), Level: "INF", Message: trimmed}: default: } - // Parse progress2 percentage and update header if m := reProgress2.FindStringSubmatch(trimmed); len(m) > 1 { pct := m[1] if pct != lastPct { @@ -210,13 +218,8 @@ func runTUI(cfg *config.Config, s *syncer.Syncer) error { return } - // Group files by top-level directory groups := groupFilesByTopLevel(result.Files) - // Per-file sizes from --progress are unreliable with --info=progress2 - // (fast transfers may skip the 100% line), so when per-file sizes - // are missing, distribute the rsync --stats total across groups - // weighted by file count. totalGroupBytes := int64(0) totalGroupFiles := 0 for _, g := range groups { @@ -242,7 +245,6 @@ func runTUI(cfg *config.Config, s *syncer.Syncer) error { } } - // Fallback: rsync ran but no individual files parsed if len(groups) == 0 && result.FilesCount > 0 { syncCh <- tui.SyncEvent{ File: fmt.Sprintf("%d files", result.FilesCount), @@ -253,7 +255,6 @@ func runTUI(cfg *config.Config, s *syncer.Syncer) error { } } - // Reset header status syncCh <- tui.SyncEvent{Status: "status:watching"} } @@ -265,27 +266,98 @@ func runTUI(cfg *config.Config, s *syncer.Syncer) error { handler, ) if err != nil { - return fmt.Errorf("creating watcher: %w", err) + cancel() + return nil, fmt.Errorf("creating watcher: %w", err) } if err := w.Start(); err != nil { - return fmt.Errorf("starting watcher: %w", err) + cancel() + return nil, fmt.Errorf("starting watcher: %w", err) } + ws.watcher = w + return ws, nil +} + +func runTUI(cfg *config.Config, s *syncer.Syncer) error { + app := tui.NewApp(cfg.Sync.Local, cfg.Sync.Remote) + syncCh := app.SyncEventChan() + logCh := app.LogEntryChan() + + ws, err := startWatching(cfg, syncCh, logCh) + if err != nil { + return err + } + + var wsMu sync.Mutex + + // Handle resync requests resyncCh := app.ResyncChan() go func() { for range resyncCh { - handler() + wsMu.Lock() + w := ws + wsMu.Unlock() + w.watcher.TriggerSync() } }() p := tea.NewProgram(app, tea.WithAltScreen()) + + // Handle config reload + configCh := app.ConfigReloadChan() + go func() { + for newCfg := range configCh { + wsMu.Lock() + oldWs := ws + wsMu.Unlock() + + // Tear down: stop watcher, wait for in-flight syncs + oldWs.watcher.Stop() + oldWs.inflight.Wait() + oldWs.cancel() + + // Rebuild with new config + newWs, err := startWatching(newCfg, syncCh, logCh) + if err != nil { + select { + case syncCh <- tui.SyncEvent{Status: "status:error"}: + default: + } + continue + } + + wsMu.Lock() + ws = newWs + wsMu.Unlock() + + // Update paths via Bubbletea message (safe — goes through Update loop) + p.Send(tui.ConfigReloadedMsg{ + Local: newCfg.Sync.Local, + Remote: newCfg.Sync.Remote, + }) + + select { + case syncCh <- tui.SyncEvent{Status: "status:watching"}: + default: + } + } + }() + if _, err := p.Run(); err != nil { - w.Stop() + wsMu.Lock() + w := ws + wsMu.Unlock() + w.watcher.Stop() + w.cancel() return fmt.Errorf("TUI error: %w", err) } - w.Stop() + wsMu.Lock() + w := ws + wsMu.Unlock() + w.watcher.Stop() + w.cancel() return nil } diff --git a/demo/esync.toml b/demo/.esync.toml diff --git a/demo/demo.tape b/demo/demo.tape @@ -36,7 +36,7 @@ Show # ── Demo ────────────────────────────────────────────────────────────────── # 1. Show the config -Type "bat esync.toml" +Type "bat .esync.toml" Enter Sleep 4s diff --git a/docs/superpowers/plans/2026-03-18-edit-config.md b/docs/superpowers/plans/2026-03-18-edit-config.md @@ -0,0 +1,878 @@ +# Edit Config from TUI — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `e` key to the TUI dashboard to open/create `.esync.toml` in `$EDITOR`, reload config on save, and rebuild watcher/syncer. + +**Architecture:** Dashboard emits `EditConfigMsg` → AppModel handles editor lifecycle via `tea.ExecProcess` → on save, parsed config is sent on `configReloadCh` → `cmd/sync.go` tears down watcher/syncer and rebuilds. Separate from this, rename `esync.toml` → `.esync.toml` everywhere. + +**Tech Stack:** Go, Bubbletea (TUI), Viper (TOML), crypto/sha256 (checksum), os/exec (editor) + +**Spec:** `docs/superpowers/specs/2026-03-18-edit-config-design.md` + +--- + +### Task 1: Rename esync.toml → .esync.toml in config search + +**Files:** +- Modify: `internal/config/config.go:119-127` — change `"./esync.toml"` to `"./.esync.toml"` in `FindConfigFile()` +- Test: `internal/config/config_test.go` (create) + +- [ ] **Step 1: Write test for FindConfigFile** + +Create `internal/config/config_test.go`: + +```go +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFindConfigInPrefersDotFile(t *testing.T) { + dir := t.TempDir() + dotFile := filepath.Join(dir, ".esync.toml") + os.WriteFile(dotFile, []byte("[sync]\n"), 0644) + + got := FindConfigIn([]string{ + filepath.Join(dir, ".esync.toml"), + filepath.Join(dir, "esync.toml"), + }) + if got != dotFile { + t.Fatalf("expected %s, got %s", dotFile, got) + } +} + +func TestFindConfigInReturnsEmpty(t *testing.T) { + got := FindConfigIn([]string{"/nonexistent/path"}) + if got != "" { + t.Fatalf("expected empty, got %s", got) + } +} +``` + +- [ ] **Step 2: Run test to verify it passes (testing FindConfigIn which is already correct)** + +Run: `go test ./internal/config/ -run TestFindConfigIn -v` +Expected: PASS — `FindConfigIn` is path-agnostic, so these tests validate it works. + +- [ ] **Step 3: Update FindConfigFile to search .esync.toml** + +In `internal/config/config.go:122`, change: + +```go +// Before +"./esync.toml", +// After +"./.esync.toml", +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/config/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/config/config.go internal/config/config_test.go +git commit -m "feat: rename config search from esync.toml to .esync.toml" +``` + +--- + +### Task 2: Update cmd/init.go, .gitignore, and demo files for .esync.toml + +**Files:** +- Modify: `cmd/init.go:52-53` — update help text +- Modify: `cmd/init.go:70` — change default path +- Modify: `.gitignore:23` — change `/esync.toml` to `/.esync.toml` +- Modify: `demo/demo.tape:39` — change `bat esync.toml` to `bat .esync.toml` +- Rename: `esync.toml.example` → `.esync.toml.example` +- Rename: `demo/esync.toml` → `demo/.esync.toml` + +- [ ] **Step 1: Update cmd/init.go default output path** + +In `cmd/init.go:70`, change: + +```go +// Before +outPath = "./esync.toml" +// After +outPath = "./.esync.toml" +``` + +- [ ] **Step 2: Update cmd/init.go command description** + +In `cmd/init.go:52-53`, change: + +```go +// Before +Short: "Generate an esync.toml configuration file", +Long: "Inspect the current directory to generate a smart esync.toml with .gitignore import and common directory exclusion.", +// After +Short: "Generate an .esync.toml configuration file", +Long: "Inspect the current directory to generate a smart .esync.toml with .gitignore import and common directory exclusion.", +``` + +- [ ] **Step 3: Update .gitignore** + +In `.gitignore:23`, change: + +``` +# Before +/esync.toml +# After +/.esync.toml +``` + +- [ ] **Step 4: Update demo/demo.tape** + +In `demo/demo.tape:39`, change: + +``` +# Before +Type "bat esync.toml" +# After +Type "bat .esync.toml" +``` + +- [ ] **Step 5: Rename files** + +```bash +git mv esync.toml.example .esync.toml.example +git mv demo/esync.toml demo/.esync.toml +``` + +- [ ] **Step 6: Build to verify** + +Run: `go build ./...` +Expected: Success + +- [ ] **Step 7: Commit** + +```bash +git add cmd/init.go .gitignore demo/demo.tape .esync.toml.example demo/.esync.toml +git commit -m "feat: update init, gitignore, demo files for .esync.toml rename" +``` + +--- + +### Task 3: Update README.md references + +**Files:** +- Modify: `README.md` — replace `esync.toml` with `.esync.toml` (multiple occurrences) + +- [ ] **Step 1: Replace all references** + +In `README.md`, replace all occurrences of `esync.toml` with `.esync.toml`. Key locations: + +- Line 75: `` `esync.toml` `` → `` `.esync.toml` `` +- Line 86: `./esync.toml` → `./.esync.toml` +- Line 130: `./esync.toml` → `./.esync.toml` +- Lines 480, 508, 532: `# esync.toml` → `# .esync.toml` + +Also update any `esync.toml.example` to `.esync.toml.example`. + +Be careful not to double-dot paths that already have a leading dot. + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: update README references from esync.toml to .esync.toml" +``` + +--- + +### Task 4: Add EditTemplateTOML() to config package + +**Files:** +- Modify: `internal/config/config.go` — add `EditTemplateTOML()` function after `DefaultTOML()` +- Modify: `internal/config/config_test.go` — add test + +- [ ] **Step 1: Write test for EditTemplateTOML** + +Add to `internal/config/config_test.go`: + +```go +import ( + "strings" + + "github.com/spf13/viper" +) +``` + +```go +func TestEditTemplateTOMLIsValidTOML(t *testing.T) { + content := EditTemplateTOML() + if content == "" { + t.Fatal("EditTemplateTOML returned empty string") + } + // Verify it contains the required fields + if !strings.Contains(content, `local = "."`) { + t.Fatal("missing local field") + } + if !strings.Contains(content, `remote = "user@host:/path/to/dest"`) { + t.Fatal("missing remote field") + } + // Verify it parses as valid TOML + v := viper.New() + v.SetConfigType("toml") + if err := v.ReadConfig(strings.NewReader(content)); err != nil { + t.Fatalf("EditTemplateTOML is not valid TOML: %v", err) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/config/ -run TestEditTemplateTOML -v` +Expected: FAIL — `EditTemplateTOML` undefined + +- [ ] **Step 3: Implement EditTemplateTOML** + +Add to `internal/config/config.go` after `DefaultTOML()`: + +```go +// EditTemplateTOML returns a minimal commented TOML template used by the +// TUI "e" key when no .esync.toml exists. Unlike DefaultTOML (used by +// esync init), most fields are commented out. +func EditTemplateTOML() string { + return `# esync configuration +# Docs: https://github.com/LouLouLibs/esync + +[sync] +local = "." +remote = "user@host:/path/to/dest" +# interval = 1 # seconds between syncs + +# [sync.ssh] +# key = "~/.ssh/id_ed25519" +# port = 22 + +[settings] +# watcher_debounce = 500 # ms +# initial_sync = false +# include = ["src/", "cmd/"] +# ignore = [".git", "*.tmp"] + +# [settings.rsync] +# archive = true +# compress = true +# delete = false +# copy_links = false +# extra_args = ["--exclude=.DS_Store"] + +# [settings.log] +# file = "esync.log" +# format = "text" +` +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/config/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/config/config.go internal/config/config_test.go +git commit -m "feat: add EditTemplateTOML for TUI config editing" +``` + +--- + +### Task 5: Add config reload channel and message types to AppModel + +**Files:** +- Modify: `internal/tui/app.go` — add types, channel, accessor, resolveEditor, UpdatePaths + +- [ ] **Step 1: Add new message types** + +In `internal/tui/app.go`, add after the `editorFinishedMsg` type (line 30): + +```go +// EditConfigMsg signals that the user wants to edit the config file. +type EditConfigMsg struct{} + +// editorConfigFinishedMsg is sent when the config editor exits. +type editorConfigFinishedMsg struct{ err error } + +// ConfigReloadedMsg signals that the config was reloaded with new paths. +type ConfigReloadedMsg struct { + Local string + Remote string +} +``` + +- [ ] **Step 2: Add fields to AppModel** + +Add to the `AppModel` struct (after `resyncCh` on line 44): + +```go +configReloadCh chan *config.Config + +// Config editor state +configTempFile string +configChecksum [32]byte +``` + +This requires adding `"crypto/sha256"` and `"github.com/louloulibs/esync/internal/config"` to the imports. + +- [ ] **Step 3: Initialize channel in NewApp** + +In `NewApp()`, add after `resyncCh` initialization: + +```go +configReloadCh: make(chan *config.Config, 1), +``` + +- [ ] **Step 4: Add ConfigReloadChan accessor** + +Add after `ResyncChan()`: + +```go +// ConfigReloadChan returns a channel that receives a new config when the user +// edits and saves the config file from the TUI. +func (m *AppModel) ConfigReloadChan() <-chan *config.Config { + return m.configReloadCh +} +``` + +- [ ] **Step 5: Add resolveEditor helper** + +Add as a package-level function: + +```go +// resolveEditor returns the user's preferred editor: $VISUAL, $EDITOR, or "vi". +func resolveEditor() string { + if e := os.Getenv("VISUAL"); e != "" { + return e + } + if e := os.Getenv("EDITOR"); e != "" { + return e + } + return "vi" +} +``` + +- [ ] **Step 6: Add UpdatePaths method** + +Add after `ConfigReloadChan()`: + +```go +// UpdatePaths updates the local and remote paths displayed in the dashboard. +// This must be called from the Bubbletea Update loop (via ConfigReloadedMsg), +// not from an external goroutine. +func (m *AppModel) updatePaths(local, remote string) { + m.dashboard.local = local + m.dashboard.remote = remote +} +``` + +Note: this is a private method — it will be called from within `Update()` when handling `ConfigReloadedMsg`, keeping all field mutations on the Bubbletea goroutine. + +- [ ] **Step 7: Build to verify** + +Run: `go build ./...` +Expected: Success (some new types unused for now, but Go only errors on unused imports, not unused types) + +- [ ] **Step 8: Commit** + +```bash +git add internal/tui/app.go +git commit -m "feat: add config reload channel, message types, and helpers to AppModel" +``` + +--- + +### Task 6: Implement editor launch and config reload in AppModel.Update() + +**Files:** +- Modify: `internal/tui/app.go` — handle `EditConfigMsg`, `editorConfigFinishedMsg`, and `ConfigReloadedMsg` in `Update()` + +- [ ] **Step 1: Handle EditConfigMsg in Update()** + +In the `Update()` switch (after the `OpenFileMsg` case block ending at line 137), add: + +```go +case EditConfigMsg: + configPath := ".esync.toml" + var targetPath string + + if _, err := os.Stat(configPath); err == nil { + // Existing file: checksum and edit in place + data, err := os.ReadFile(configPath) + if err != nil { + return m, nil + } + m.configChecksum = sha256.Sum256(data) + m.configTempFile = "" + targetPath = configPath + } else { + // New file: write template to temp file + tmpFile, err := os.CreateTemp("", "esync-*.toml") + if err != nil { + return m, nil + } + tmpl := config.EditTemplateTOML() + tmpFile.WriteString(tmpl) + tmpFile.Close() + m.configChecksum = sha256.Sum256([]byte(tmpl)) + m.configTempFile = tmpFile.Name() + targetPath = tmpFile.Name() + } + + editor := resolveEditor() + c := exec.Command(editor, targetPath) + return m, tea.ExecProcess(c, func(err error) tea.Msg { + return editorConfigFinishedMsg{err} + }) +``` + +- [ ] **Step 2: Handle editorConfigFinishedMsg in Update()** + +Add after the `EditConfigMsg` case: + +```go +case editorConfigFinishedMsg: + if msg.err != nil { + // Editor exited with error — discard + if m.configTempFile != "" { + os.Remove(m.configTempFile) + m.configTempFile = "" + } + return m, nil + } + + configPath := ".esync.toml" + editedPath := configPath + if m.configTempFile != "" { + editedPath = m.configTempFile + } + + data, err := os.ReadFile(editedPath) + if err != nil { + if m.configTempFile != "" { + os.Remove(m.configTempFile) + m.configTempFile = "" + } + return m, nil + } + + newChecksum := sha256.Sum256(data) + if newChecksum == m.configChecksum { + // No changes + if m.configTempFile != "" { + os.Remove(m.configTempFile) + m.configTempFile = "" + } + return m, nil + } + + // Changed — if temp, persist to .esync.toml + if m.configTempFile != "" { + if err := os.WriteFile(configPath, data, 0644); err != nil { + m.dashboard.status = "error: could not write " + configPath + os.Remove(m.configTempFile) + m.configTempFile = "" + return m, nil + } + os.Remove(m.configTempFile) + m.configTempFile = "" + } + + // Parse the new config + cfg, err := config.Load(configPath) + if err != nil { + m.dashboard.status = "config error: " + err.Error() + return m, nil + } + + // Send to reload channel (non-blocking) + select { + case m.configReloadCh <- cfg: + default: + } + return m, nil +``` + +- [ ] **Step 3: Handle ConfigReloadedMsg in Update()** + +Add after the `editorConfigFinishedMsg` case: + +```go +case ConfigReloadedMsg: + m.updatePaths(msg.Local, msg.Remote) + return m, nil +``` + +- [ ] **Step 4: Build to verify** + +Run: `go build ./...` +Expected: Success + +- [ ] **Step 5: Commit** + +```bash +git add internal/tui/app.go +git commit -m "feat: implement config editor launch and reload in AppModel" +``` + +--- + +### Task 7: Add "e" key binding and help line to dashboard + +**Files:** +- Modify: `internal/tui/dashboard.go:143-215` — add `e` key case in `updateNormal()` +- Modify: `internal/tui/dashboard.go:370-378` — add `e config` to help line + +- [ ] **Step 1: Add "e" key in updateNormal** + +In `internal/tui/dashboard.go`, in `updateNormal()`, add a new case before `case "/"` (before line 208): + +```go +case "e": + return m, func() tea.Msg { return EditConfigMsg{} } +``` + +- [ ] **Step 2: Add "e config" to help line** + +In the help line section (around lines 376-377), insert `e config` between `v view` and `l logs`: + +```go +// Before +helpKey("v") + helpDesc("view") + +helpKey("l") + helpDesc("logs") + +// After +helpKey("v") + helpDesc("view") + +helpKey("e") + helpDesc("config") + +helpKey("l") + helpDesc("logs") + +``` + +- [ ] **Step 3: Build and run tests** + +Run: `go build ./... && go test ./internal/tui/ -v` +Expected: Success + +- [ ] **Step 4: Commit** + +```bash +git add internal/tui/dashboard.go +git commit -m "feat: add 'e' key binding for config editing in dashboard" +``` + +--- + +### Task 8: Add TriggerSync to watcher + +**Files:** +- Modify: `internal/watcher/watcher.go` — add `TriggerSync()` method + +- [ ] **Step 1: Add TriggerSync** + +In `internal/watcher/watcher.go`, add after `Stop()` (after line 149): + +```go +// TriggerSync immediately invokes the sync handler (bypasses debounce). +func (w *Watcher) TriggerSync() { + w.debouncer.callback() +} +``` + +Note: This calls the callback directly rather than going through `Trigger()`, which would add a debounce delay. This preserves the existing resync-key behavior of immediate execution. + +- [ ] **Step 2: Build to verify** + +Run: `go build ./...` +Expected: Success + +- [ ] **Step 3: Commit** + +```bash +git add internal/watcher/watcher.go +git commit -m "feat: add TriggerSync for immediate sync without debounce" +``` + +--- + +### Task 9: Refactor runTUI to support config reload + +**Files:** +- Modify: `cmd/sync.go:163-290` — extract `startWatching` helper, add reload goroutine with proper synchronization + +This is the largest task. It refactors `runTUI` to: +1. Extract watcher/syncer setup into a reusable `startWatching` helper +2. Add a goroutine that listens for config reload and rebuilds +3. Protect shared `watchState` with a mutex +4. Use a `sync.WaitGroup` to wait for in-flight syncs during teardown +5. Route path updates through `p.Send()` to stay on the Bubbletea goroutine + +- [ ] **Step 1: Add watchState struct and startWatching helper** + +Add before `runTUI` in `cmd/sync.go`: + +```go +// watchState holds the watcher and syncer that can be torn down and rebuilt. +type watchState struct { + watcher *watcher.Watcher + cancel context.CancelFunc + inflight sync.WaitGroup +} + +// startWatching creates a syncer, watcher, and sync handler from the given config. +// The handler pushes events to syncCh and logCh. Returns the watchState for teardown. +func startWatching(cfg *config.Config, syncCh chan<- tui.SyncEvent, logCh chan<- tui.LogEntry) (*watchState, error) { + ctx, cancel := context.WithCancel(context.Background()) + + s := syncer.New(cfg) + s.DryRun = dryRun + + ws := &watchState{cancel: cancel} + + handler := func() { + ws.inflight.Add(1) + defer ws.inflight.Done() + + syncCh <- tui.SyncEvent{Status: "status:syncing"} + + var lastPct string + onLine := func(line string) { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + return + } + select { + case logCh <- tui.LogEntry{Time: time.Now(), Level: "INF", Message: trimmed}: + default: + } + if m := reProgress2.FindStringSubmatch(trimmed); len(m) > 1 { + pct := m[1] + if pct != lastPct { + lastPct = pct + select { + case syncCh <- tui.SyncEvent{Status: "status:syncing " + pct + "%"}: + default: + } + } + } + } + + result, err := s.RunWithProgress(ctx, onLine) + now := time.Now() + + if err != nil { + syncCh <- tui.SyncEvent{ + File: "sync error", + Status: "error", + Time: now, + } + syncCh <- tui.SyncEvent{Status: "status:watching"} + return + } + + groups := groupFilesByTopLevel(result.Files) + + totalGroupBytes := int64(0) + totalGroupFiles := 0 + for _, g := range groups { + totalGroupBytes += g.bytes + totalGroupFiles += g.count + } + + for _, g := range groups { + file := g.name + bytes := g.bytes + if totalGroupBytes == 0 && result.BytesTotal > 0 && totalGroupFiles > 0 { + bytes = result.BytesTotal * int64(g.count) / int64(totalGroupFiles) + } + size := formatSize(bytes) + syncCh <- tui.SyncEvent{ + File: file, + Size: size, + Duration: result.Duration, + Status: "synced", + Time: now, + Files: truncateFiles(g.files, 10), + FileCount: g.count, + } + } + + if len(groups) == 0 && result.FilesCount > 0 { + syncCh <- tui.SyncEvent{ + File: fmt.Sprintf("%d files", result.FilesCount), + Size: formatSize(result.BytesTotal), + Duration: result.Duration, + Status: "synced", + Time: now, + } + } + + syncCh <- tui.SyncEvent{Status: "status:watching"} + } + + w, err := watcher.New( + cfg.Sync.Local, + cfg.Settings.WatcherDebounce, + cfg.AllIgnorePatterns(), + cfg.Settings.Include, + handler, + ) + if err != nil { + cancel() + return nil, fmt.Errorf("creating watcher: %w", err) + } + + if err := w.Start(); err != nil { + cancel() + return nil, fmt.Errorf("starting watcher: %w", err) + } + + ws.watcher = w + return ws, nil +} +``` + +- [ ] **Step 2: Rewrite runTUI** + +Replace the entire `runTUI` function: + +```go +func runTUI(cfg *config.Config, s *syncer.Syncer) error { + app := tui.NewApp(cfg.Sync.Local, cfg.Sync.Remote) + syncCh := app.SyncEventChan() + logCh := app.LogEntryChan() + + ws, err := startWatching(cfg, syncCh, logCh) + if err != nil { + return err + } + + var wsMu sync.Mutex + + // Handle resync requests + resyncCh := app.ResyncChan() + go func() { + for range resyncCh { + wsMu.Lock() + w := ws + wsMu.Unlock() + w.watcher.TriggerSync() + } + }() + + p := tea.NewProgram(app, tea.WithAltScreen()) + + // Handle config reload + configCh := app.ConfigReloadChan() + go func() { + for newCfg := range configCh { + wsMu.Lock() + oldWs := ws + wsMu.Unlock() + + // Tear down: stop watcher, wait for in-flight syncs + oldWs.watcher.Stop() + oldWs.inflight.Wait() + oldWs.cancel() + + // Rebuild with new config + newWs, err := startWatching(newCfg, syncCh, logCh) + if err != nil { + select { + case syncCh <- tui.SyncEvent{Status: "status:error"}: + default: + } + continue + } + + wsMu.Lock() + ws = newWs + wsMu.Unlock() + + // Update paths via Bubbletea message (safe — goes through Update loop) + p.Send(tui.ConfigReloadedMsg{ + Local: newCfg.Sync.Local, + Remote: newCfg.Sync.Remote, + }) + } + }() + + if _, err := p.Run(); err != nil { + wsMu.Lock() + w := ws + wsMu.Unlock() + w.watcher.Stop() + w.cancel() + return fmt.Errorf("TUI error: %w", err) + } + + wsMu.Lock() + w := ws + wsMu.Unlock() + w.watcher.Stop() + w.cancel() + return nil +} +``` + +Add `"sync"` to the imports in `cmd/sync.go`. + +- [ ] **Step 3: Build and test** + +Run: `go build ./... && go test ./...` +Expected: Success + +- [ ] **Step 4: Commit** + +```bash +git add cmd/sync.go +git commit -m "refactor: extract startWatching, add config reload with mutex and WaitGroup" +``` + +--- + +### Task 10: End-to-end manual test + +- [ ] **Step 1: Build the binary** + +Run: `GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o esync-darwin-arm64 .` + +- [ ] **Step 2: Test with existing config** + +1. Create `.esync.toml` with valid local/remote +2. Run `./esync-darwin-arm64 sync` +3. Press `e` — verify editor opens with the config +4. Make a change (e.g., add an ignore pattern), save, exit +5. Verify TUI shows "watching" (config reloaded) + +- [ ] **Step 3: Test new config creation** + +1. Remove `.esync.toml` +2. Run `./esync-darwin-arm64 sync -l . -r user@host:/tmp/test` +3. Press `e` — verify editor opens with the template +4. Fill in `local`/`remote`, save, exit +5. Verify `.esync.toml` was created and TUI continues running + +- [ ] **Step 4: Test discard on exit without save** + +1. Remove `.esync.toml` +2. Run `./esync-darwin-arm64 sync -l . -r user@host:/tmp/test` +3. Press `e` — editor opens with template +4. Exit without saving (`:q!` in vim) +5. Verify no `.esync.toml` was created + +- [ ] **Step 5: Test invalid config** + +1. Press `e`, introduce a TOML syntax error, save +2. Verify TUI shows error in status line and keeps running with old config + +- [ ] **Step 6: Run full test suite** + +Run: `go test ./...` +Expected: All pass diff --git a/docs/superpowers/specs/2026-03-18-edit-config-design.md b/docs/superpowers/specs/2026-03-18-edit-config-design.md @@ -0,0 +1,125 @@ +# Edit Config from TUI + +## Summary + +Add an `e` key to the TUI dashboard that opens `.esync.toml` in `$EDITOR`. On save, the config is reloaded and the watcher/syncer are rebuilt with the new values. If no config file exists, a template is created; if the user exits without saving, nothing is persisted. + +Also renames the project-level config from `esync.toml` to `.esync.toml` (dotfile). + +## Config File Rename + +- `FindConfigFile()` in `internal/config/config.go` searches `./.esync.toml` instead of `./esync.toml` +- System-level paths (`~/.config/esync/config.toml`, `/etc/esync/config.toml`) remain unchanged +- `cmd/init.go` default output path changes from `./esync.toml` to `./.esync.toml` +- `esync.toml.example` renamed to `.esync.toml.example` +- `README.md` references updated from `esync.toml` to `.esync.toml` +- The `e` key always targets `./.esync.toml` in cwd + +## Key Flow + +### 1. Keypress + +Dashboard handles `e` keypress in `updateNormal()` and sends `EditConfigMsg{}` up to `AppModel`. Only active in dashboard view (not logs). Already gated by `updateNormal` vs `updateFiltering` dispatch, so typing `e` during filter input is safe. + +### 2. AppModel receives EditConfigMsg + +- Target path: `./.esync.toml` (cwd) +- **File exists:** record SHA-256 checksum, open in editor via `tea.ExecProcess` +- **File does not exist:** write template to a temp file (with `.toml` suffix for syntax highlighting, e.g. `os.CreateTemp("", "esync-*.toml")`), record its checksum, open temp file in editor +- **Editor resolution:** check `$VISUAL`, then `$EDITOR`, fall back to `vi` + +### 3. Editor exits (editorConfigFinishedMsg) + +- **New file flow (was temp):** compare checksums. If unchanged, delete temp file, done. If changed, copy temp contents to `./.esync.toml`, delete temp file. +- **Existing file flow:** compare checksums. If unchanged, done. +- **Config changed:** parse with `config.Load()`. + - **Parse fails:** push error to TUI status line (e.g., "config error: missing sync.remote"), keep old config running. + - **Parse succeeds:** send new `*config.Config` on `configReloadCh` channel. + +Note: `tea.ExecProcess` blocks the TUI program, so the user cannot press `e` again while the editor is open. This makes the capacity-1 channel safe without needing non-blocking sends. + +### 4. cmd/sync.go handles reload + +Listens on `app.ConfigReloadChan()`: + +1. Stop existing watcher (blocks until `<-w.done`, ensuring no in-flight handler) +2. Wait for any in-flight sync to complete before proceeding +3. Rebuild syncer with new config +4. Create new watcher with new config values (local path, debounce, ignore patterns, includes) +5. Create new sync handler closure capturing the new syncer +6. Push status event: "config reloaded" + +### 5. Flag-only mode (--local/--remote without config file) + +When esync was started with `--local`/`--remote` flags and no config file, pressing `e` still opens `./.esync.toml`. If the file doesn't exist, the template is shown. After save, the reloaded config replaces the flag-derived config entirely (CLI flags are not re-applied on top). This lets users transition from quick flag-based usage to a persistent config file. + +## Template Content + +A new `EditTemplateTOML()` function in `internal/config/config.go`, separate from the existing `DefaultTOML()` used by `esync init`. The edit template is minimal with most options commented out, while `DefaultTOML()` remains unchanged for `esync init`'s string-replacement logic. + +```toml +# esync configuration +# Docs: https://github.com/LouLouLibs/esync + +[sync] +local = "." +remote = "user@host:/path/to/dest" +# interval = 1 # seconds between syncs + +# [sync.ssh] +# key = "~/.ssh/id_ed25519" +# port = 22 + +[settings] +# watcher_debounce = 500 # ms +# initial_sync = false +# include = ["src/", "cmd/"] +# ignore = [".git", "*.tmp"] + +# [settings.rsync] +# archive = true +# compress = true +# delete = false +# copy_links = false +# extra_args = ["--exclude=.DS_Store"] + +# [settings.log] +# file = "esync.log" +# format = "text" +``` + +## Help Bar + +Updated dashboard help line: + +``` +q quit p pause r resync ↑↓ navigate enter expand v view e config l logs / filter +``` + +`e config` inserted between `v view` and `l logs`, using existing `helpKey()`/`helpDesc()` styling. + +## New Types and Channels + +| Item | Location | Purpose | +|------|----------|---------| +| `EditConfigMsg` | `internal/tui/app.go` | Dashboard → AppModel signal | +| `editorConfigFinishedMsg` | `internal/tui/app.go` | Editor exit result (distinct from existing `editorFinishedMsg`) | +| `configReloadCh` | `AppModel` field | `chan *config.Config`, capacity 1 | +| `ConfigReloadChan()` | `AppModel` method | Exposes channel to `cmd/sync.go` | + +## Error Handling + +- Bad TOML or missing required fields: status line error, old config retained +- Editor not set: check `$VISUAL` → `$EDITOR` → `vi` +- Editor returns non-zero exit: treat as "no change", discard +- Watcher detects `.esync.toml` write: harmless (rsync transfers the small file). Not added to default ignore since users may intentionally sync config files. + +## Files Modified + +- `internal/config/config.go` — rename `esync.toml` → `.esync.toml` in `FindConfigFile()`, add `EditTemplateTOML()` +- `internal/tui/app.go` — new message types, `configReloadCh`, editor launch/return handling +- `internal/tui/dashboard.go` — `e` key binding, help line update +- `cmd/sync.go` — listen on `configReloadCh`, tear down and rebuild watcher/syncer +- `cmd/init.go` — update default output path to `./.esync.toml`, update `defaultIgnorePatterns` +- `esync.toml.example` — rename to `.esync.toml.example` +- `README.md` — update references from `esync.toml` to `.esync.toml` diff --git a/internal/config/config.go b/internal/config/config.go @@ -119,7 +119,7 @@ func Load(path string) (*Config, error) { func FindConfigFile() string { home, _ := os.UserHomeDir() candidates := []string{ - "./esync.toml", + "./.esync.toml", home + "/.config/esync/config.toml", "/etc/esync/config.toml", } @@ -179,6 +179,41 @@ func (c *Config) AllIgnorePatterns() []string { // DefaultTOML // --------------------------------------------------------------------------- +// EditTemplateTOML returns a minimal commented TOML template used by the +// TUI "e" key when no .esync.toml exists. Unlike DefaultTOML (used by +// esync init), most fields are commented out. +func EditTemplateTOML() string { + return `# esync configuration +# Docs: https://github.com/LouLouLibs/esync + +[sync] +local = "." +remote = "user@host:/path/to/dest" +# interval = 1 # seconds between syncs + +# [sync.ssh] +# key = "~/.ssh/id_ed25519" +# port = 22 + +[settings] +# watcher_debounce = 500 # ms +# initial_sync = false +# include = ["src/", "cmd/"] +# ignore = [".git", "*.tmp"] + +# [settings.rsync] +# archive = true +# compress = true +# delete = false +# copy_links = false +# extra_args = ["--exclude=.DS_Store"] + +# [settings.log] +# file = "esync.log" +# format = "text" +` +} + // DefaultTOML returns a commented TOML template suitable for writing to a // new configuration file. func DefaultTOML() string { diff --git a/internal/config/config_test.go b/internal/config/config_test.go @@ -3,7 +3,10 @@ package config import ( "os" "path/filepath" + "strings" "testing" + + "github.com/spf13/viper" ) // --- Helper: write a TOML string to a temp file and return its path --- @@ -306,6 +309,27 @@ func TestFindConfigFileNotFound(t *testing.T) { } } +func TestFindConfigInPrefersDotFile(t *testing.T) { + dir := t.TempDir() + dotFile := filepath.Join(dir, ".esync.toml") + os.WriteFile(dotFile, []byte("[sync]\n"), 0644) + + got := FindConfigIn([]string{ + filepath.Join(dir, ".esync.toml"), + filepath.Join(dir, "esync.toml"), + }) + if got != dotFile { + t.Fatalf("expected %s, got %s", dotFile, got) + } +} + +func TestFindConfigInReturnsEmpty(t *testing.T) { + got := FindConfigIn([]string{"/nonexistent/path"}) + if got != "" { + t.Fatalf("expected empty, got %s", got) + } +} + // ----------------------------------------------------------------------- // 7. TestAllIgnorePatterns — combines both ignore lists // ----------------------------------------------------------------------- @@ -409,6 +433,27 @@ func TestDefaultTOML(t *testing.T) { } } +// ----------------------------------------------------------------------- +// 11. TestEditTemplateTOMLIsValidTOML — returns valid TOML with required fields +// ----------------------------------------------------------------------- +func TestEditTemplateTOMLIsValidTOML(t *testing.T) { + content := EditTemplateTOML() + if content == "" { + t.Fatal("EditTemplateTOML returned empty string") + } + if !strings.Contains(content, `local = "."`) { + t.Fatal("missing local field") + } + if !strings.Contains(content, `remote = "user@host:/path/to/dest"`) { + t.Fatal("missing remote field") + } + v := viper.New() + v.SetConfigType("toml") + if err := v.ReadConfig(strings.NewReader(content)); err != nil { + t.Fatalf("EditTemplateTOML is not valid TOML: %v", err) + } +} + func containsString(s, substr string) bool { return len(s) >= len(substr) && searchString(s, substr) } diff --git a/internal/tui/app.go b/internal/tui/app.go @@ -1,10 +1,12 @@ package tui import ( + "crypto/sha256" "os" "os/exec" tea "github.com/charmbracelet/bubbletea" + "github.com/louloulibs/esync/internal/config" ) // --------------------------------------------------------------------------- @@ -29,6 +31,18 @@ type OpenFileMsg struct{ Path string } type editorFinishedMsg struct{ err error } +// EditConfigMsg signals that the user wants to edit the config file. +type EditConfigMsg struct{} + +// editorConfigFinishedMsg is sent when the config editor exits. +type editorConfigFinishedMsg struct{ err error } + +// ConfigReloadedMsg signals that the config was reloaded with new paths. +type ConfigReloadedMsg struct { + Local string + Remote string +} + // --------------------------------------------------------------------------- // AppModel — root Bubbletea model // --------------------------------------------------------------------------- @@ -41,7 +55,12 @@ type AppModel struct { current view syncEvents chan SyncEvent logEntries chan LogEntry - resyncCh chan struct{} + resyncCh chan struct{} + configReloadCh chan *config.Config + + // Config editor state + configTempFile string + configChecksum [32]byte } // NewApp creates a new AppModel wired to the given local and remote paths. @@ -52,7 +71,8 @@ func NewApp(local, remote string) *AppModel { current: viewDashboard, syncEvents: make(chan SyncEvent, 64), logEntries: make(chan LogEntry, 64), - resyncCh: make(chan struct{}, 1), + resyncCh: make(chan struct{}, 1), + configReloadCh: make(chan *config.Config, 1), } } @@ -73,6 +93,12 @@ func (m *AppModel) ResyncChan() <-chan struct{} { return m.resyncCh } +// ConfigReloadChan returns a channel that receives a new config when the user +// edits and saves the config file from the TUI. +func (m *AppModel) ConfigReloadChan() <-chan *config.Config { + return m.configReloadCh +} + // --------------------------------------------------------------------------- // tea.Model interface // --------------------------------------------------------------------------- @@ -140,6 +166,108 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Editor exited; nothing to do on success. return m, nil + case EditConfigMsg: + configPath := ".esync.toml" + var targetPath string + + if _, err := os.Stat(configPath); err == nil { + // Existing file: checksum and edit in place + data, err := os.ReadFile(configPath) + if err != nil { + return m, nil + } + m.configChecksum = sha256.Sum256(data) + m.configTempFile = "" + targetPath = configPath + } else { + // New file: write template to temp file + tmpFile, err := os.CreateTemp("", "esync-*.toml") + if err != nil { + return m, nil + } + tmpl := config.EditTemplateTOML() + if _, err := tmpFile.WriteString(tmpl); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + return m, nil + } + tmpFile.Close() + m.configChecksum = sha256.Sum256([]byte(tmpl)) + m.configTempFile = tmpFile.Name() + targetPath = tmpFile.Name() + } + + editor := resolveEditor() + c := exec.Command(editor, targetPath) + return m, tea.ExecProcess(c, func(err error) tea.Msg { + return editorConfigFinishedMsg{err} + }) + + case editorConfigFinishedMsg: + if msg.err != nil { + // Editor exited with error — discard + if m.configTempFile != "" { + os.Remove(m.configTempFile) + m.configTempFile = "" + } + return m, nil + } + + configPath := ".esync.toml" + editedPath := configPath + if m.configTempFile != "" { + editedPath = m.configTempFile + } + + data, err := os.ReadFile(editedPath) + if err != nil { + if m.configTempFile != "" { + os.Remove(m.configTempFile) + m.configTempFile = "" + } + return m, nil + } + + newChecksum := sha256.Sum256(data) + if newChecksum == m.configChecksum { + // No changes + if m.configTempFile != "" { + os.Remove(m.configTempFile) + m.configTempFile = "" + } + return m, nil + } + + // Changed — if temp, persist to .esync.toml + if m.configTempFile != "" { + if err := os.WriteFile(configPath, data, 0644); err != nil { + m.dashboard.status = "error: could not write " + configPath + os.Remove(m.configTempFile) + m.configTempFile = "" + return m, nil + } + os.Remove(m.configTempFile) + m.configTempFile = "" + } + + // Parse the new config + cfg, err := config.Load(configPath) + if err != nil { + m.dashboard.status = "config error: " + err.Error() + return m, nil + } + + // Send to reload channel (non-blocking) + select { + case m.configReloadCh <- cfg: + default: + } + return m, nil + + case ConfigReloadedMsg: + m.updatePaths(msg.Local, msg.Remote) + return m, nil + case SyncEventMsg: // Dispatch to dashboard and re-listen. var cmd tea.Cmd @@ -205,3 +333,24 @@ func (m AppModel) listenLogEntries() tea.Cmd { return LogEntryMsg(<-ch) } } + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// resolveEditor returns the user's preferred editor: $VISUAL, $EDITOR, or "vi". +func resolveEditor() string { + if e := os.Getenv("VISUAL"); e != "" { + return e + } + if e := os.Getenv("EDITOR"); e != "" { + return e + } + return "vi" +} + +// updatePaths updates the local and remote paths displayed in the dashboard. +func (m *AppModel) updatePaths(local, remote string) { + m.dashboard.local = local + m.dashboard.remote = remote +} diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go @@ -205,6 +205,8 @@ func (m DashboardModel) updateNormal(msg tea.KeyMsg) (DashboardModel, tea.Cmd) { // Single-file event — open it path := filepath.Join(m.local, evt.File) return m, func() tea.Msg { return OpenFileMsg{Path: path} } + case "e": + return m, func() tea.Msg { return EditConfigMsg{} } case "/": m.filtering = true m.filter = "" @@ -374,6 +376,7 @@ func (m DashboardModel) View() string { helpKey("↑↓") + helpDesc("navigate") + helpKey("enter") + helpDesc("expand") + helpKey("v") + helpDesc("view") + + helpKey("e") + helpDesc("config") + helpKey("l") + helpDesc("logs") + helpKey("/") + helpDesc("filter") if m.filter != "" { diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go @@ -148,6 +148,17 @@ func (w *Watcher) Stop() { <-w.done } +// TriggerSync immediately invokes the sync handler (bypasses debounce). +// Safe to call after Stop — checks the stopped flag before invoking. +func (w *Watcher) TriggerSync() { + w.debouncer.mu.Lock() + stopped := w.debouncer.stopped + w.debouncer.mu.Unlock() + if !stopped { + w.debouncer.callback() + } +} + // --------------------------------------------------------------------------- // Private methods // ---------------------------------------------------------------------------