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