esync

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

2026-03-18-edit-config.md (21402B)


      1 # Edit Config from TUI — Implementation Plan
      2 
      3 > **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.
      4 
      5 **Goal:** Add `e` key to the TUI dashboard to open/create `.esync.toml` in `$EDITOR`, reload config on save, and rebuild watcher/syncer.
      6 
      7 **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.
      8 
      9 **Tech Stack:** Go, Bubbletea (TUI), Viper (TOML), crypto/sha256 (checksum), os/exec (editor)
     10 
     11 **Spec:** `docs/superpowers/specs/2026-03-18-edit-config-design.md`
     12 
     13 ---
     14 
     15 ### Task 1: Rename esync.toml → .esync.toml in config search
     16 
     17 **Files:**
     18 - Modify: `internal/config/config.go:119-127` — change `"./esync.toml"` to `"./.esync.toml"` in `FindConfigFile()`
     19 - Test: `internal/config/config_test.go` (create)
     20 
     21 - [ ] **Step 1: Write test for FindConfigFile**
     22 
     23 Create `internal/config/config_test.go`:
     24 
     25 ```go
     26 package config
     27 
     28 import (
     29 	"os"
     30 	"path/filepath"
     31 	"testing"
     32 )
     33 
     34 func TestFindConfigInPrefersDotFile(t *testing.T) {
     35 	dir := t.TempDir()
     36 	dotFile := filepath.Join(dir, ".esync.toml")
     37 	os.WriteFile(dotFile, []byte("[sync]\n"), 0644)
     38 
     39 	got := FindConfigIn([]string{
     40 		filepath.Join(dir, ".esync.toml"),
     41 		filepath.Join(dir, "esync.toml"),
     42 	})
     43 	if got != dotFile {
     44 		t.Fatalf("expected %s, got %s", dotFile, got)
     45 	}
     46 }
     47 
     48 func TestFindConfigInReturnsEmpty(t *testing.T) {
     49 	got := FindConfigIn([]string{"/nonexistent/path"})
     50 	if got != "" {
     51 		t.Fatalf("expected empty, got %s", got)
     52 	}
     53 }
     54 ```
     55 
     56 - [ ] **Step 2: Run test to verify it passes (testing FindConfigIn which is already correct)**
     57 
     58 Run: `go test ./internal/config/ -run TestFindConfigIn -v`
     59 Expected: PASS — `FindConfigIn` is path-agnostic, so these tests validate it works.
     60 
     61 - [ ] **Step 3: Update FindConfigFile to search .esync.toml**
     62 
     63 In `internal/config/config.go:122`, change:
     64 
     65 ```go
     66 // Before
     67 "./esync.toml",
     68 // After
     69 "./.esync.toml",
     70 ```
     71 
     72 - [ ] **Step 4: Run tests**
     73 
     74 Run: `go test ./internal/config/ -v`
     75 Expected: PASS
     76 
     77 - [ ] **Step 5: Commit**
     78 
     79 ```bash
     80 git add internal/config/config.go internal/config/config_test.go
     81 git commit -m "feat: rename config search from esync.toml to .esync.toml"
     82 ```
     83 
     84 ---
     85 
     86 ### Task 2: Update cmd/init.go, .gitignore, and demo files for .esync.toml
     87 
     88 **Files:**
     89 - Modify: `cmd/init.go:52-53` — update help text
     90 - Modify: `cmd/init.go:70` — change default path
     91 - Modify: `.gitignore:23` — change `/esync.toml` to `/.esync.toml`
     92 - Modify: `demo/demo.tape:39` — change `bat esync.toml` to `bat .esync.toml`
     93 - Rename: `esync.toml.example` → `.esync.toml.example`
     94 - Rename: `demo/esync.toml` → `demo/.esync.toml`
     95 
     96 - [ ] **Step 1: Update cmd/init.go default output path**
     97 
     98 In `cmd/init.go:70`, change:
     99 
    100 ```go
    101 // Before
    102 outPath = "./esync.toml"
    103 // After
    104 outPath = "./.esync.toml"
    105 ```
    106 
    107 - [ ] **Step 2: Update cmd/init.go command description**
    108 
    109 In `cmd/init.go:52-53`, change:
    110 
    111 ```go
    112 // Before
    113 Short: "Generate an esync.toml configuration file",
    114 Long:  "Inspect the current directory to generate a smart esync.toml with .gitignore import and common directory exclusion.",
    115 // After
    116 Short: "Generate an .esync.toml configuration file",
    117 Long:  "Inspect the current directory to generate a smart .esync.toml with .gitignore import and common directory exclusion.",
    118 ```
    119 
    120 - [ ] **Step 3: Update .gitignore**
    121 
    122 In `.gitignore:23`, change:
    123 
    124 ```
    125 # Before
    126 /esync.toml
    127 # After
    128 /.esync.toml
    129 ```
    130 
    131 - [ ] **Step 4: Update demo/demo.tape**
    132 
    133 In `demo/demo.tape:39`, change:
    134 
    135 ```
    136 # Before
    137 Type "bat esync.toml"
    138 # After
    139 Type "bat .esync.toml"
    140 ```
    141 
    142 - [ ] **Step 5: Rename files**
    143 
    144 ```bash
    145 git mv esync.toml.example .esync.toml.example
    146 git mv demo/esync.toml demo/.esync.toml
    147 ```
    148 
    149 - [ ] **Step 6: Build to verify**
    150 
    151 Run: `go build ./...`
    152 Expected: Success
    153 
    154 - [ ] **Step 7: Commit**
    155 
    156 ```bash
    157 git add cmd/init.go .gitignore demo/demo.tape .esync.toml.example demo/.esync.toml
    158 git commit -m "feat: update init, gitignore, demo files for .esync.toml rename"
    159 ```
    160 
    161 ---
    162 
    163 ### Task 3: Update README.md references
    164 
    165 **Files:**
    166 - Modify: `README.md` — replace `esync.toml` with `.esync.toml` (multiple occurrences)
    167 
    168 - [ ] **Step 1: Replace all references**
    169 
    170 In `README.md`, replace all occurrences of `esync.toml` with `.esync.toml`. Key locations:
    171 
    172 - Line 75: `` `esync.toml` `` → `` `.esync.toml` ``
    173 - Line 86: `./esync.toml` → `./.esync.toml`
    174 - Line 130: `./esync.toml` → `./.esync.toml`
    175 - Lines 480, 508, 532: `# esync.toml` → `# .esync.toml`
    176 
    177 Also update any `esync.toml.example` to `.esync.toml.example`.
    178 
    179 Be careful not to double-dot paths that already have a leading dot.
    180 
    181 - [ ] **Step 2: Commit**
    182 
    183 ```bash
    184 git add README.md
    185 git commit -m "docs: update README references from esync.toml to .esync.toml"
    186 ```
    187 
    188 ---
    189 
    190 ### Task 4: Add EditTemplateTOML() to config package
    191 
    192 **Files:**
    193 - Modify: `internal/config/config.go` — add `EditTemplateTOML()` function after `DefaultTOML()`
    194 - Modify: `internal/config/config_test.go` — add test
    195 
    196 - [ ] **Step 1: Write test for EditTemplateTOML**
    197 
    198 Add to `internal/config/config_test.go`:
    199 
    200 ```go
    201 import (
    202 	"strings"
    203 
    204 	"github.com/spf13/viper"
    205 )
    206 ```
    207 
    208 ```go
    209 func TestEditTemplateTOMLIsValidTOML(t *testing.T) {
    210 	content := EditTemplateTOML()
    211 	if content == "" {
    212 		t.Fatal("EditTemplateTOML returned empty string")
    213 	}
    214 	// Verify it contains the required fields
    215 	if !strings.Contains(content, `local = "."`) {
    216 		t.Fatal("missing local field")
    217 	}
    218 	if !strings.Contains(content, `remote = "user@host:/path/to/dest"`) {
    219 		t.Fatal("missing remote field")
    220 	}
    221 	// Verify it parses as valid TOML
    222 	v := viper.New()
    223 	v.SetConfigType("toml")
    224 	if err := v.ReadConfig(strings.NewReader(content)); err != nil {
    225 		t.Fatalf("EditTemplateTOML is not valid TOML: %v", err)
    226 	}
    227 }
    228 ```
    229 
    230 - [ ] **Step 2: Run test to verify it fails**
    231 
    232 Run: `go test ./internal/config/ -run TestEditTemplateTOML -v`
    233 Expected: FAIL — `EditTemplateTOML` undefined
    234 
    235 - [ ] **Step 3: Implement EditTemplateTOML**
    236 
    237 Add to `internal/config/config.go` after `DefaultTOML()`:
    238 
    239 ```go
    240 // EditTemplateTOML returns a minimal commented TOML template used by the
    241 // TUI "e" key when no .esync.toml exists. Unlike DefaultTOML (used by
    242 // esync init), most fields are commented out.
    243 func EditTemplateTOML() string {
    244 	return `# esync configuration
    245 # Docs: https://github.com/LouLouLibs/esync
    246 
    247 [sync]
    248 local = "."
    249 remote = "user@host:/path/to/dest"
    250 # interval = 1  # seconds between syncs
    251 
    252 # [sync.ssh]
    253 # key = "~/.ssh/id_ed25519"
    254 # port = 22
    255 
    256 [settings]
    257 # watcher_debounce = 500   # ms
    258 # initial_sync = false
    259 # include = ["src/", "cmd/"]
    260 # ignore = [".git", "*.tmp"]
    261 
    262 # [settings.rsync]
    263 # archive = true
    264 # compress = true
    265 # delete = false
    266 # copy_links = false
    267 # extra_args = ["--exclude=.DS_Store"]
    268 
    269 # [settings.log]
    270 # file = "esync.log"
    271 # format = "text"
    272 `
    273 }
    274 ```
    275 
    276 - [ ] **Step 4: Run tests**
    277 
    278 Run: `go test ./internal/config/ -v`
    279 Expected: PASS
    280 
    281 - [ ] **Step 5: Commit**
    282 
    283 ```bash
    284 git add internal/config/config.go internal/config/config_test.go
    285 git commit -m "feat: add EditTemplateTOML for TUI config editing"
    286 ```
    287 
    288 ---
    289 
    290 ### Task 5: Add config reload channel and message types to AppModel
    291 
    292 **Files:**
    293 - Modify: `internal/tui/app.go` — add types, channel, accessor, resolveEditor, UpdatePaths
    294 
    295 - [ ] **Step 1: Add new message types**
    296 
    297 In `internal/tui/app.go`, add after the `editorFinishedMsg` type (line 30):
    298 
    299 ```go
    300 // EditConfigMsg signals that the user wants to edit the config file.
    301 type EditConfigMsg struct{}
    302 
    303 // editorConfigFinishedMsg is sent when the config editor exits.
    304 type editorConfigFinishedMsg struct{ err error }
    305 
    306 // ConfigReloadedMsg signals that the config was reloaded with new paths.
    307 type ConfigReloadedMsg struct {
    308 	Local  string
    309 	Remote string
    310 }
    311 ```
    312 
    313 - [ ] **Step 2: Add fields to AppModel**
    314 
    315 Add to the `AppModel` struct (after `resyncCh` on line 44):
    316 
    317 ```go
    318 configReloadCh chan *config.Config
    319 
    320 // Config editor state
    321 configTempFile string
    322 configChecksum [32]byte
    323 ```
    324 
    325 This requires adding `"crypto/sha256"` and `"github.com/louloulibs/esync/internal/config"` to the imports.
    326 
    327 - [ ] **Step 3: Initialize channel in NewApp**
    328 
    329 In `NewApp()`, add after `resyncCh` initialization:
    330 
    331 ```go
    332 configReloadCh: make(chan *config.Config, 1),
    333 ```
    334 
    335 - [ ] **Step 4: Add ConfigReloadChan accessor**
    336 
    337 Add after `ResyncChan()`:
    338 
    339 ```go
    340 // ConfigReloadChan returns a channel that receives a new config when the user
    341 // edits and saves the config file from the TUI.
    342 func (m *AppModel) ConfigReloadChan() <-chan *config.Config {
    343 	return m.configReloadCh
    344 }
    345 ```
    346 
    347 - [ ] **Step 5: Add resolveEditor helper**
    348 
    349 Add as a package-level function:
    350 
    351 ```go
    352 // resolveEditor returns the user's preferred editor: $VISUAL, $EDITOR, or "vi".
    353 func resolveEditor() string {
    354 	if e := os.Getenv("VISUAL"); e != "" {
    355 		return e
    356 	}
    357 	if e := os.Getenv("EDITOR"); e != "" {
    358 		return e
    359 	}
    360 	return "vi"
    361 }
    362 ```
    363 
    364 - [ ] **Step 6: Add UpdatePaths method**
    365 
    366 Add after `ConfigReloadChan()`:
    367 
    368 ```go
    369 // UpdatePaths updates the local and remote paths displayed in the dashboard.
    370 // This must be called from the Bubbletea Update loop (via ConfigReloadedMsg),
    371 // not from an external goroutine.
    372 func (m *AppModel) updatePaths(local, remote string) {
    373 	m.dashboard.local = local
    374 	m.dashboard.remote = remote
    375 }
    376 ```
    377 
    378 Note: this is a private method — it will be called from within `Update()` when handling `ConfigReloadedMsg`, keeping all field mutations on the Bubbletea goroutine.
    379 
    380 - [ ] **Step 7: Build to verify**
    381 
    382 Run: `go build ./...`
    383 Expected: Success (some new types unused for now, but Go only errors on unused imports, not unused types)
    384 
    385 - [ ] **Step 8: Commit**
    386 
    387 ```bash
    388 git add internal/tui/app.go
    389 git commit -m "feat: add config reload channel, message types, and helpers to AppModel"
    390 ```
    391 
    392 ---
    393 
    394 ### Task 6: Implement editor launch and config reload in AppModel.Update()
    395 
    396 **Files:**
    397 - Modify: `internal/tui/app.go` — handle `EditConfigMsg`, `editorConfigFinishedMsg`, and `ConfigReloadedMsg` in `Update()`
    398 
    399 - [ ] **Step 1: Handle EditConfigMsg in Update()**
    400 
    401 In the `Update()` switch (after the `OpenFileMsg` case block ending at line 137), add:
    402 
    403 ```go
    404 case EditConfigMsg:
    405 	configPath := ".esync.toml"
    406 	var targetPath string
    407 
    408 	if _, err := os.Stat(configPath); err == nil {
    409 		// Existing file: checksum and edit in place
    410 		data, err := os.ReadFile(configPath)
    411 		if err != nil {
    412 			return m, nil
    413 		}
    414 		m.configChecksum = sha256.Sum256(data)
    415 		m.configTempFile = ""
    416 		targetPath = configPath
    417 	} else {
    418 		// New file: write template to temp file
    419 		tmpFile, err := os.CreateTemp("", "esync-*.toml")
    420 		if err != nil {
    421 			return m, nil
    422 		}
    423 		tmpl := config.EditTemplateTOML()
    424 		tmpFile.WriteString(tmpl)
    425 		tmpFile.Close()
    426 		m.configChecksum = sha256.Sum256([]byte(tmpl))
    427 		m.configTempFile = tmpFile.Name()
    428 		targetPath = tmpFile.Name()
    429 	}
    430 
    431 	editor := resolveEditor()
    432 	c := exec.Command(editor, targetPath)
    433 	return m, tea.ExecProcess(c, func(err error) tea.Msg {
    434 		return editorConfigFinishedMsg{err}
    435 	})
    436 ```
    437 
    438 - [ ] **Step 2: Handle editorConfigFinishedMsg in Update()**
    439 
    440 Add after the `EditConfigMsg` case:
    441 
    442 ```go
    443 case editorConfigFinishedMsg:
    444 	if msg.err != nil {
    445 		// Editor exited with error — discard
    446 		if m.configTempFile != "" {
    447 			os.Remove(m.configTempFile)
    448 			m.configTempFile = ""
    449 		}
    450 		return m, nil
    451 	}
    452 
    453 	configPath := ".esync.toml"
    454 	editedPath := configPath
    455 	if m.configTempFile != "" {
    456 		editedPath = m.configTempFile
    457 	}
    458 
    459 	data, err := os.ReadFile(editedPath)
    460 	if err != nil {
    461 		if m.configTempFile != "" {
    462 			os.Remove(m.configTempFile)
    463 			m.configTempFile = ""
    464 		}
    465 		return m, nil
    466 	}
    467 
    468 	newChecksum := sha256.Sum256(data)
    469 	if newChecksum == m.configChecksum {
    470 		// No changes
    471 		if m.configTempFile != "" {
    472 			os.Remove(m.configTempFile)
    473 			m.configTempFile = ""
    474 		}
    475 		return m, nil
    476 	}
    477 
    478 	// Changed — if temp, persist to .esync.toml
    479 	if m.configTempFile != "" {
    480 		if err := os.WriteFile(configPath, data, 0644); err != nil {
    481 			m.dashboard.status = "error: could not write " + configPath
    482 			os.Remove(m.configTempFile)
    483 			m.configTempFile = ""
    484 			return m, nil
    485 		}
    486 		os.Remove(m.configTempFile)
    487 		m.configTempFile = ""
    488 	}
    489 
    490 	// Parse the new config
    491 	cfg, err := config.Load(configPath)
    492 	if err != nil {
    493 		m.dashboard.status = "config error: " + err.Error()
    494 		return m, nil
    495 	}
    496 
    497 	// Send to reload channel (non-blocking)
    498 	select {
    499 	case m.configReloadCh <- cfg:
    500 	default:
    501 	}
    502 	return m, nil
    503 ```
    504 
    505 - [ ] **Step 3: Handle ConfigReloadedMsg in Update()**
    506 
    507 Add after the `editorConfigFinishedMsg` case:
    508 
    509 ```go
    510 case ConfigReloadedMsg:
    511 	m.updatePaths(msg.Local, msg.Remote)
    512 	return m, nil
    513 ```
    514 
    515 - [ ] **Step 4: Build to verify**
    516 
    517 Run: `go build ./...`
    518 Expected: Success
    519 
    520 - [ ] **Step 5: Commit**
    521 
    522 ```bash
    523 git add internal/tui/app.go
    524 git commit -m "feat: implement config editor launch and reload in AppModel"
    525 ```
    526 
    527 ---
    528 
    529 ### Task 7: Add "e" key binding and help line to dashboard
    530 
    531 **Files:**
    532 - Modify: `internal/tui/dashboard.go:143-215` — add `e` key case in `updateNormal()`
    533 - Modify: `internal/tui/dashboard.go:370-378` — add `e config` to help line
    534 
    535 - [ ] **Step 1: Add "e" key in updateNormal**
    536 
    537 In `internal/tui/dashboard.go`, in `updateNormal()`, add a new case before `case "/"` (before line 208):
    538 
    539 ```go
    540 case "e":
    541 	return m, func() tea.Msg { return EditConfigMsg{} }
    542 ```
    543 
    544 - [ ] **Step 2: Add "e config" to help line**
    545 
    546 In the help line section (around lines 376-377), insert `e config` between `v view` and `l logs`:
    547 
    548 ```go
    549 // Before
    550 helpKey("v") + helpDesc("view") +
    551 helpKey("l") + helpDesc("logs") +
    552 // After
    553 helpKey("v") + helpDesc("view") +
    554 helpKey("e") + helpDesc("config") +
    555 helpKey("l") + helpDesc("logs") +
    556 ```
    557 
    558 - [ ] **Step 3: Build and run tests**
    559 
    560 Run: `go build ./... && go test ./internal/tui/ -v`
    561 Expected: Success
    562 
    563 - [ ] **Step 4: Commit**
    564 
    565 ```bash
    566 git add internal/tui/dashboard.go
    567 git commit -m "feat: add 'e' key binding for config editing in dashboard"
    568 ```
    569 
    570 ---
    571 
    572 ### Task 8: Add TriggerSync to watcher
    573 
    574 **Files:**
    575 - Modify: `internal/watcher/watcher.go` — add `TriggerSync()` method
    576 
    577 - [ ] **Step 1: Add TriggerSync**
    578 
    579 In `internal/watcher/watcher.go`, add after `Stop()` (after line 149):
    580 
    581 ```go
    582 // TriggerSync immediately invokes the sync handler (bypasses debounce).
    583 func (w *Watcher) TriggerSync() {
    584 	w.debouncer.callback()
    585 }
    586 ```
    587 
    588 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.
    589 
    590 - [ ] **Step 2: Build to verify**
    591 
    592 Run: `go build ./...`
    593 Expected: Success
    594 
    595 - [ ] **Step 3: Commit**
    596 
    597 ```bash
    598 git add internal/watcher/watcher.go
    599 git commit -m "feat: add TriggerSync for immediate sync without debounce"
    600 ```
    601 
    602 ---
    603 
    604 ### Task 9: Refactor runTUI to support config reload
    605 
    606 **Files:**
    607 - Modify: `cmd/sync.go:163-290` — extract `startWatching` helper, add reload goroutine with proper synchronization
    608 
    609 This is the largest task. It refactors `runTUI` to:
    610 1. Extract watcher/syncer setup into a reusable `startWatching` helper
    611 2. Add a goroutine that listens for config reload and rebuilds
    612 3. Protect shared `watchState` with a mutex
    613 4. Use a `sync.WaitGroup` to wait for in-flight syncs during teardown
    614 5. Route path updates through `p.Send()` to stay on the Bubbletea goroutine
    615 
    616 - [ ] **Step 1: Add watchState struct and startWatching helper**
    617 
    618 Add before `runTUI` in `cmd/sync.go`:
    619 
    620 ```go
    621 // watchState holds the watcher and syncer that can be torn down and rebuilt.
    622 type watchState struct {
    623 	watcher  *watcher.Watcher
    624 	cancel   context.CancelFunc
    625 	inflight sync.WaitGroup
    626 }
    627 
    628 // startWatching creates a syncer, watcher, and sync handler from the given config.
    629 // The handler pushes events to syncCh and logCh. Returns the watchState for teardown.
    630 func startWatching(cfg *config.Config, syncCh chan<- tui.SyncEvent, logCh chan<- tui.LogEntry) (*watchState, error) {
    631 	ctx, cancel := context.WithCancel(context.Background())
    632 
    633 	s := syncer.New(cfg)
    634 	s.DryRun = dryRun
    635 
    636 	ws := &watchState{cancel: cancel}
    637 
    638 	handler := func() {
    639 		ws.inflight.Add(1)
    640 		defer ws.inflight.Done()
    641 
    642 		syncCh <- tui.SyncEvent{Status: "status:syncing"}
    643 
    644 		var lastPct string
    645 		onLine := func(line string) {
    646 			trimmed := strings.TrimSpace(line)
    647 			if trimmed == "" {
    648 				return
    649 			}
    650 			select {
    651 			case logCh <- tui.LogEntry{Time: time.Now(), Level: "INF", Message: trimmed}:
    652 			default:
    653 			}
    654 			if m := reProgress2.FindStringSubmatch(trimmed); len(m) > 1 {
    655 				pct := m[1]
    656 				if pct != lastPct {
    657 					lastPct = pct
    658 					select {
    659 					case syncCh <- tui.SyncEvent{Status: "status:syncing " + pct + "%"}:
    660 					default:
    661 					}
    662 				}
    663 			}
    664 		}
    665 
    666 		result, err := s.RunWithProgress(ctx, onLine)
    667 		now := time.Now()
    668 
    669 		if err != nil {
    670 			syncCh <- tui.SyncEvent{
    671 				File:   "sync error",
    672 				Status: "error",
    673 				Time:   now,
    674 			}
    675 			syncCh <- tui.SyncEvent{Status: "status:watching"}
    676 			return
    677 		}
    678 
    679 		groups := groupFilesByTopLevel(result.Files)
    680 
    681 		totalGroupBytes := int64(0)
    682 		totalGroupFiles := 0
    683 		for _, g := range groups {
    684 			totalGroupBytes += g.bytes
    685 			totalGroupFiles += g.count
    686 		}
    687 
    688 		for _, g := range groups {
    689 			file := g.name
    690 			bytes := g.bytes
    691 			if totalGroupBytes == 0 && result.BytesTotal > 0 && totalGroupFiles > 0 {
    692 				bytes = result.BytesTotal * int64(g.count) / int64(totalGroupFiles)
    693 			}
    694 			size := formatSize(bytes)
    695 			syncCh <- tui.SyncEvent{
    696 				File:      file,
    697 				Size:      size,
    698 				Duration:  result.Duration,
    699 				Status:    "synced",
    700 				Time:      now,
    701 				Files:     truncateFiles(g.files, 10),
    702 				FileCount: g.count,
    703 			}
    704 		}
    705 
    706 		if len(groups) == 0 && result.FilesCount > 0 {
    707 			syncCh <- tui.SyncEvent{
    708 				File:     fmt.Sprintf("%d files", result.FilesCount),
    709 				Size:     formatSize(result.BytesTotal),
    710 				Duration: result.Duration,
    711 				Status:   "synced",
    712 				Time:     now,
    713 			}
    714 		}
    715 
    716 		syncCh <- tui.SyncEvent{Status: "status:watching"}
    717 	}
    718 
    719 	w, err := watcher.New(
    720 		cfg.Sync.Local,
    721 		cfg.Settings.WatcherDebounce,
    722 		cfg.AllIgnorePatterns(),
    723 		cfg.Settings.Include,
    724 		handler,
    725 	)
    726 	if err != nil {
    727 		cancel()
    728 		return nil, fmt.Errorf("creating watcher: %w", err)
    729 	}
    730 
    731 	if err := w.Start(); err != nil {
    732 		cancel()
    733 		return nil, fmt.Errorf("starting watcher: %w", err)
    734 	}
    735 
    736 	ws.watcher = w
    737 	return ws, nil
    738 }
    739 ```
    740 
    741 - [ ] **Step 2: Rewrite runTUI**
    742 
    743 Replace the entire `runTUI` function:
    744 
    745 ```go
    746 func runTUI(cfg *config.Config, s *syncer.Syncer) error {
    747 	app := tui.NewApp(cfg.Sync.Local, cfg.Sync.Remote)
    748 	syncCh := app.SyncEventChan()
    749 	logCh := app.LogEntryChan()
    750 
    751 	ws, err := startWatching(cfg, syncCh, logCh)
    752 	if err != nil {
    753 		return err
    754 	}
    755 
    756 	var wsMu sync.Mutex
    757 
    758 	// Handle resync requests
    759 	resyncCh := app.ResyncChan()
    760 	go func() {
    761 		for range resyncCh {
    762 			wsMu.Lock()
    763 			w := ws
    764 			wsMu.Unlock()
    765 			w.watcher.TriggerSync()
    766 		}
    767 	}()
    768 
    769 	p := tea.NewProgram(app, tea.WithAltScreen())
    770 
    771 	// Handle config reload
    772 	configCh := app.ConfigReloadChan()
    773 	go func() {
    774 		for newCfg := range configCh {
    775 			wsMu.Lock()
    776 			oldWs := ws
    777 			wsMu.Unlock()
    778 
    779 			// Tear down: stop watcher, wait for in-flight syncs
    780 			oldWs.watcher.Stop()
    781 			oldWs.inflight.Wait()
    782 			oldWs.cancel()
    783 
    784 			// Rebuild with new config
    785 			newWs, err := startWatching(newCfg, syncCh, logCh)
    786 			if err != nil {
    787 				select {
    788 				case syncCh <- tui.SyncEvent{Status: "status:error"}:
    789 				default:
    790 				}
    791 				continue
    792 			}
    793 
    794 			wsMu.Lock()
    795 			ws = newWs
    796 			wsMu.Unlock()
    797 
    798 			// Update paths via Bubbletea message (safe — goes through Update loop)
    799 			p.Send(tui.ConfigReloadedMsg{
    800 				Local:  newCfg.Sync.Local,
    801 				Remote: newCfg.Sync.Remote,
    802 			})
    803 		}
    804 	}()
    805 
    806 	if _, err := p.Run(); err != nil {
    807 		wsMu.Lock()
    808 		w := ws
    809 		wsMu.Unlock()
    810 		w.watcher.Stop()
    811 		w.cancel()
    812 		return fmt.Errorf("TUI error: %w", err)
    813 	}
    814 
    815 	wsMu.Lock()
    816 	w := ws
    817 	wsMu.Unlock()
    818 	w.watcher.Stop()
    819 	w.cancel()
    820 	return nil
    821 }
    822 ```
    823 
    824 Add `"sync"` to the imports in `cmd/sync.go`.
    825 
    826 - [ ] **Step 3: Build and test**
    827 
    828 Run: `go build ./... && go test ./...`
    829 Expected: Success
    830 
    831 - [ ] **Step 4: Commit**
    832 
    833 ```bash
    834 git add cmd/sync.go
    835 git commit -m "refactor: extract startWatching, add config reload with mutex and WaitGroup"
    836 ```
    837 
    838 ---
    839 
    840 ### Task 10: End-to-end manual test
    841 
    842 - [ ] **Step 1: Build the binary**
    843 
    844 Run: `GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o esync-darwin-arm64 .`
    845 
    846 - [ ] **Step 2: Test with existing config**
    847 
    848 1. Create `.esync.toml` with valid local/remote
    849 2. Run `./esync-darwin-arm64 sync`
    850 3. Press `e` — verify editor opens with the config
    851 4. Make a change (e.g., add an ignore pattern), save, exit
    852 5. Verify TUI shows "watching" (config reloaded)
    853 
    854 - [ ] **Step 3: Test new config creation**
    855 
    856 1. Remove `.esync.toml`
    857 2. Run `./esync-darwin-arm64 sync -l . -r user@host:/tmp/test`
    858 3. Press `e` — verify editor opens with the template
    859 4. Fill in `local`/`remote`, save, exit
    860 5. Verify `.esync.toml` was created and TUI continues running
    861 
    862 - [ ] **Step 4: Test discard on exit without save**
    863 
    864 1. Remove `.esync.toml`
    865 2. Run `./esync-darwin-arm64 sync -l . -r user@host:/tmp/test`
    866 3. Press `e` — editor opens with template
    867 4. Exit without saving (`:q!` in vim)
    868 5. Verify no `.esync.toml` was created
    869 
    870 - [ ] **Step 5: Test invalid config**
    871 
    872 1. Press `e`, introduce a TOML syntax error, save
    873 2. Verify TUI shows error in status line and keeps running with old config
    874 
    875 - [ ] **Step 6: Run full test suite**
    876 
    877 Run: `go test ./...`
    878 Expected: All pass