esync

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

2026-03-01-tui-improvements-plan.md (12379B)


      1 # TUI Improvements Implementation Plan
      2 
      3 > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
      4 
      5 **Goal:** Fix the dashboard event list to show grouped top-level results with timestamps, fill the terminal, scroll, and wire up the `r` resync key.
      6 
      7 **Architecture:** Replace per-file "syncing"/"synced" events with a status message for the header and grouped directory-level events. Add scroll offset to the dashboard. Add a resync channel so the TUI can trigger a full sync. Accumulate stats in the handler.
      8 
      9 **Tech Stack:** Go, Bubbletea, Lipgloss
     10 
     11 ---
     12 
     13 ### Task 1: Add new message types to app.go
     14 
     15 **Files:**
     16 - Modify: `internal/tui/app.go`
     17 
     18 **Step 1: Add SyncStatusMsg and ResyncRequestMsg types and resync channel**
     19 
     20 In `internal/tui/app.go`, add after the `view` constants (line 16):
     21 
     22 ```go
     23 // SyncStatusMsg updates the header status without adding an event.
     24 type SyncStatusMsg string
     25 
     26 // ResyncRequestMsg signals that the user pressed 'r' for a full resync.
     27 type ResyncRequestMsg struct{}
     28 ```
     29 
     30 Add `resyncCh` field to `AppModel` struct (after `logEntries`):
     31 
     32 ```go
     33 resyncCh   chan struct{}
     34 ```
     35 
     36 Initialize it in `NewApp`:
     37 
     38 ```go
     39 resyncCh:   make(chan struct{}, 1),
     40 ```
     41 
     42 Add accessor:
     43 
     44 ```go
     45 // ResyncChan returns a channel that receives when the user requests a full resync.
     46 func (m *AppModel) ResyncChan() <-chan struct{} {
     47 	return m.resyncCh
     48 }
     49 ```
     50 
     51 **Step 2: Handle new messages in AppModel.Update**
     52 
     53 In the `Update` method's switch, add cases before the `SyncEventMsg` case:
     54 
     55 ```go
     56 case SyncStatusMsg:
     57 	m.dashboard.status = string(msg)
     58 	return m, nil
     59 
     60 case ResyncRequestMsg:
     61 	select {
     62 	case m.resyncCh <- struct{}{}:
     63 	default:
     64 	}
     65 	return m, nil
     66 ```
     67 
     68 **Step 3: Build and verify compilation**
     69 
     70 Run: `go build ./...`
     71 Expected: success
     72 
     73 **Step 4: Commit**
     74 
     75 ```bash
     76 git add internal/tui/app.go
     77 git commit -m "feat(tui): add SyncStatusMsg, ResyncRequestMsg, resync channel"
     78 ```
     79 
     80 ---
     81 
     82 ### Task 2: Update dashboard — timestamps, scrolling, fill terminal
     83 
     84 **Files:**
     85 - Modify: `internal/tui/dashboard.go`
     86 
     87 **Step 1: Add scroll offset field**
     88 
     89 Add `offset int` to `DashboardModel` struct (after `filtering`).
     90 
     91 **Step 2: Add j/k/up/down scroll keys and r resync key to updateNormal**
     92 
     93 Replace `updateNormal`:
     94 
     95 ```go
     96 func (m DashboardModel) updateNormal(msg tea.KeyMsg) (DashboardModel, tea.Cmd) {
     97 	switch msg.String() {
     98 	case "q", "ctrl+c":
     99 		return m, tea.Quit
    100 	case "p":
    101 		if m.status == "paused" {
    102 			m.status = "watching"
    103 		} else {
    104 			m.status = "paused"
    105 		}
    106 	case "r":
    107 		return m, func() tea.Msg { return ResyncRequestMsg{} }
    108 	case "j", "down":
    109 		filtered := m.filteredEvents()
    110 		maxOffset := max(0, len(filtered)-m.eventViewHeight())
    111 		if m.offset < maxOffset {
    112 			m.offset++
    113 		}
    114 	case "k", "up":
    115 		if m.offset > 0 {
    116 			m.offset--
    117 		}
    118 	case "/":
    119 		m.filtering = true
    120 		m.filter = ""
    121 		m.offset = 0
    122 	}
    123 	return m, nil
    124 }
    125 ```
    126 
    127 **Step 3: Add eventViewHeight helper**
    128 
    129 ```go
    130 // eventViewHeight returns the number of event rows that fit in the terminal.
    131 // Layout: header (3 lines) + "Recent" header (1) + stats section (3) + help (1) = 8 fixed.
    132 func (m DashboardModel) eventViewHeight() int {
    133 	return max(1, m.height-8)
    134 }
    135 ```
    136 
    137 **Step 4: Rewrite View to fill terminal with timestamps**
    138 
    139 Replace the `View` method:
    140 
    141 ```go
    142 func (m DashboardModel) View() string {
    143 	var b strings.Builder
    144 
    145 	// --- Header (3 lines) ---
    146 	header := titleStyle.Render(" esync ") + dimStyle.Render(strings.Repeat("─", max(0, m.width-8)))
    147 	b.WriteString(header + "\n")
    148 	b.WriteString(fmt.Sprintf("  %s → %s\n", m.local, m.remote))
    149 
    150 	statusIcon, statusText := m.statusDisplay()
    151 	agoText := ""
    152 	if !m.lastSync.IsZero() {
    153 		ago := time.Since(m.lastSync).Truncate(time.Second)
    154 		agoText = fmt.Sprintf(" (synced %s ago)", ago)
    155 	}
    156 	b.WriteString(fmt.Sprintf("  %s %s%s\n", statusIcon, statusText, dimStyle.Render(agoText)))
    157 
    158 	// --- Recent events ---
    159 	b.WriteString("  " + titleStyle.Render("Recent") + " " + dimStyle.Render(strings.Repeat("─", max(0, m.width-11))) + "\n")
    160 
    161 	filtered := m.filteredEvents()
    162 	vh := m.eventViewHeight()
    163 	start := m.offset
    164 	end := min(start+vh, len(filtered))
    165 
    166 	for i := start; i < end; i++ {
    167 		b.WriteString("  " + m.renderEvent(filtered[i]) + "\n")
    168 	}
    169 	// Pad empty rows
    170 	for i := end - start; i < vh; i++ {
    171 		b.WriteString("\n")
    172 	}
    173 
    174 	// --- Stats (2 lines) ---
    175 	b.WriteString("  " + titleStyle.Render("Stats") + " " + dimStyle.Render(strings.Repeat("─", max(0, m.width-10))) + "\n")
    176 	stats := fmt.Sprintf("  %d synced │ %s total │ %d errors",
    177 		m.totalSynced, m.totalBytes, m.totalErrors)
    178 	b.WriteString(stats + "\n")
    179 
    180 	// --- Help (1 line) ---
    181 	if m.filtering {
    182 		b.WriteString(helpStyle.Render(fmt.Sprintf("  filter: %s█  (enter apply  esc clear)", m.filter)))
    183 	} else {
    184 		help := "  q quit  p pause  r resync  ↑↓ scroll  l logs  / filter"
    185 		if m.filter != "" {
    186 			help += fmt.Sprintf("  [filter: %s]", m.filter)
    187 		}
    188 		b.WriteString(helpStyle.Render(help))
    189 	}
    190 	b.WriteString("\n")
    191 
    192 	return b.String()
    193 }
    194 ```
    195 
    196 **Step 5: Update renderEvent to include timestamp**
    197 
    198 Replace `renderEvent`:
    199 
    200 ```go
    201 func (m DashboardModel) renderEvent(evt SyncEvent) string {
    202 	ts := dimStyle.Render(evt.Time.Format("15:04:05"))
    203 	switch evt.Status {
    204 	case "synced":
    205 		name := padRight(evt.File, 30)
    206 		detail := ""
    207 		if evt.Size != "" {
    208 			detail = dimStyle.Render(fmt.Sprintf("%8s  %s", evt.Size, evt.Duration.Truncate(100*time.Millisecond)))
    209 		}
    210 		return ts + "  " + statusSynced.Render("✓") + " " + name + detail
    211 	case "error":
    212 		name := padRight(evt.File, 30)
    213 		return ts + "  " + statusError.Render("✗") + " " + name + statusError.Render("error")
    214 	default:
    215 		return ts + "  " + evt.File
    216 	}
    217 }
    218 ```
    219 
    220 **Step 6: Remove "syncing" case from renderEvent**
    221 
    222 The "syncing" case is no longer needed — it was removed in step 5 above.
    223 
    224 **Step 7: Build and verify**
    225 
    226 Run: `go build ./...`
    227 Expected: success
    228 
    229 **Step 8: Commit**
    230 
    231 ```bash
    232 git add internal/tui/dashboard.go
    233 git commit -m "feat(tui): timestamps, scrolling, fill terminal, resync key"
    234 ```
    235 
    236 ---
    237 
    238 ### Task 3: Top-level grouping and stats accumulation in handler
    239 
    240 **Files:**
    241 - Modify: `cmd/sync.go`
    242 
    243 **Step 1: Add groupFiles helper**
    244 
    245 Add after `formatSize` at the bottom of `cmd/sync.go`:
    246 
    247 ```go
    248 // groupedEvent represents a top-level directory or root file for the TUI.
    249 type groupedEvent struct {
    250 	name  string // "cmd/" or "main.go"
    251 	count int    // number of files (1 for root files)
    252 	bytes int64  // total bytes
    253 }
    254 
    255 // groupFilesByTopLevel collapses file entries into top-level directories
    256 // and root files. "cmd/sync.go" + "cmd/init.go" → one entry "cmd/" with count=2.
    257 func groupFilesByTopLevel(files []syncer.FileEntry) []groupedEvent {
    258 	dirMap := make(map[string]*groupedEvent)
    259 	var rootFiles []groupedEvent
    260 	var dirOrder []string
    261 
    262 	for _, f := range files {
    263 		parts := strings.SplitN(f.Name, "/", 2)
    264 		if len(parts) == 1 {
    265 			// Root-level file
    266 			rootFiles = append(rootFiles, groupedEvent{
    267 				name:  f.Name,
    268 				count: 1,
    269 				bytes: f.Bytes,
    270 			})
    271 		} else {
    272 			dir := parts[0] + "/"
    273 			if g, ok := dirMap[dir]; ok {
    274 				g.count++
    275 				g.bytes += f.Bytes
    276 			} else {
    277 				dirMap[dir] = &groupedEvent{name: dir, count: 1, bytes: f.Bytes}
    278 				dirOrder = append(dirOrder, dir)
    279 			}
    280 		}
    281 	}
    282 
    283 	var out []groupedEvent
    284 	for _, dir := range dirOrder {
    285 		out = append(out, *dirMap[dir])
    286 	}
    287 	out = append(out, rootFiles...)
    288 	return out
    289 }
    290 ```
    291 
    292 **Step 2: Rewrite TUI handler to use grouping, stats, and status messages**
    293 
    294 Replace the entire `handler` closure inside `runTUI`:
    295 
    296 ```go
    297 	var totalSynced int
    298 	var totalBytes int64
    299 	var totalErrors int
    300 
    301 	handler := func() {
    302 		// Update header status
    303 		syncCh <- tui.SyncEvent{Status: "status:syncing"}
    304 
    305 		result, err := s.Run()
    306 		now := time.Now()
    307 
    308 		if err != nil {
    309 			totalErrors++
    310 			syncCh <- tui.SyncEvent{
    311 				File:   "sync error",
    312 				Status: "error",
    313 				Time:   now,
    314 			}
    315 			// Reset header
    316 			syncCh <- tui.SyncEvent{Status: "status:watching"}
    317 			return
    318 		}
    319 
    320 		// Group files by top-level directory
    321 		groups := groupFilesByTopLevel(result.Files)
    322 		for _, g := range groups {
    323 			file := g.name
    324 			size := formatSize(g.bytes)
    325 			if g.count > 1 {
    326 				file = g.name
    327 				size = fmt.Sprintf("%d files  %s", g.count, formatSize(g.bytes))
    328 			}
    329 			syncCh <- tui.SyncEvent{
    330 				File:     file,
    331 				Size:     size,
    332 				Duration: result.Duration,
    333 				Status:   "synced",
    334 				Time:     now,
    335 			}
    336 		}
    337 
    338 		// Fallback: rsync ran but no individual files parsed
    339 		if len(groups) == 0 && result.FilesCount > 0 {
    340 			syncCh <- tui.SyncEvent{
    341 				File:     fmt.Sprintf("%d files", result.FilesCount),
    342 				Size:     formatSize(result.BytesTotal),
    343 				Duration: result.Duration,
    344 				Status:   "synced",
    345 				Time:     now,
    346 			}
    347 		}
    348 
    349 		// Accumulate stats
    350 		totalSynced += result.FilesCount
    351 		totalBytes += result.BytesTotal
    352 
    353 		// Reset header
    354 		syncCh <- tui.SyncEvent{Status: "status:watching"}
    355 	}
    356 ```
    357 
    358 **Step 3: Handle status messages in dashboard Update**
    359 
    360 In `internal/tui/dashboard.go`, update the `SyncEventMsg` case in `Update`:
    361 
    362 ```go
    363 case SyncEventMsg:
    364 	evt := SyncEvent(msg)
    365 
    366 	// Status-only messages update the header, not the event list
    367 	if strings.HasPrefix(evt.Status, "status:") {
    368 		m.status = strings.TrimPrefix(evt.Status, "status:")
    369 		return m, nil
    370 	}
    371 
    372 	// Prepend event; cap at 500.
    373 	m.events = append([]SyncEvent{evt}, m.events...)
    374 	if len(m.events) > 500 {
    375 		m.events = m.events[:500]
    376 	}
    377 	if evt.Status == "synced" {
    378 		m.lastSync = evt.Time
    379 	}
    380 	return m, nil
    381 ```
    382 
    383 Add `"strings"` to the imports in `dashboard.go` if not already present (it is).
    384 
    385 **Step 4: Send stats after each sync**
    386 
    387 Still in the TUI handler in `cmd/sync.go`, after the status reset, send stats. But we're using the same `syncCh` channel which sends `SyncEvent`. We need a different approach.
    388 
    389 Simpler: update the dashboard's stats directly from the event stream. In `dashboard.go`, update the `SyncEventMsg` handler to accumulate stats:
    390 
    391 ```go
    392 if evt.Status == "synced" {
    393 	m.lastSync = evt.Time
    394 	m.totalSynced++
    395 	// Parse size back (or just count events)
    396 }
    397 ```
    398 
    399 Actually, the simplest approach: count synced events and track the `lastSync` time. Remove the `SyncStatsMsg` type and the `totalBytes` / `totalErrors` fields. Replace the stats bar with just event count + last sync time. The exact byte total isn't meaningful in grouped view anyway.
    400 
    401 Replace stats rendering in `View`:
    402 
    403 ```go
    404 // --- Stats (2 lines) ---
    405 b.WriteString("  " + titleStyle.Render("Stats") + " " + dimStyle.Render(strings.Repeat("─", max(0, m.width-10))) + "\n")
    406 stats := fmt.Sprintf("  %d events │ %d errors", m.totalSynced, m.totalErrors)
    407 b.WriteString(stats + "\n")
    408 ```
    409 
    410 In the `SyncEventMsg` handler, increment counters:
    411 
    412 ```go
    413 if evt.Status == "synced" {
    414 	m.lastSync = evt.Time
    415 	m.totalSynced++
    416 } else if evt.Status == "error" {
    417 	m.totalErrors++
    418 }
    419 ```
    420 
    421 Remove `totalBytes string` from `DashboardModel` and `SyncStatsMsg` type from `dashboard.go`. Remove the `SyncStatsMsg` case from `Update`.
    422 
    423 **Step 5: Wire up resync channel in cmd/sync.go**
    424 
    425 In `runTUI`, after starting the watcher and before creating the tea.Program, add a goroutine:
    426 
    427 ```go
    428 	resyncCh := app.ResyncChan()
    429 	go func() {
    430 		for range resyncCh {
    431 			handler()
    432 		}
    433 	}()
    434 ```
    435 
    436 **Step 6: Build and verify**
    437 
    438 Run: `go build ./...`
    439 Expected: success
    440 
    441 **Step 7: Run tests**
    442 
    443 Run: `go test ./...`
    444 Expected: all pass
    445 
    446 **Step 8: Commit**
    447 
    448 ```bash
    449 git add cmd/sync.go internal/tui/dashboard.go
    450 git commit -m "feat(tui): top-level grouping, stats accumulation, resync wiring"
    451 ```
    452 
    453 ---
    454 
    455 ### Task 4: End-to-end verification
    456 
    457 **Step 1: Build binary**
    458 
    459 ```bash
    460 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o esync-darwin-arm64 .
    461 ```
    462 
    463 **Step 2: Test with local docs sync**
    464 
    465 ```bash
    466 rm -rf /tmp/esync-docs && mkdir -p /tmp/esync-docs
    467 ./esync-darwin-arm64 sync --daemon -v
    468 # In another terminal: touch docs/plans/2026-03-01-go-rewrite-design.md
    469 # Verify: "Synced 2 files" appears
    470 ```
    471 
    472 **Step 3: Test TUI**
    473 
    474 ```bash
    475 ./esync-darwin-arm64 sync
    476 # Verify:
    477 # - Header shows "● Watching", switches to "⟳ Syncing" during rsync
    478 # - Events show timestamps: "15:04:05 ✓ plans/ ..."
    479 # - j/k scrolls the event list
    480 # - r triggers a full resync
    481 # - Event list fills terminal height
    482 # - No "⟳ . syncing..." rows in the event list
    483 ```
    484 
    485 **Step 4: Run full test suite**
    486 
    487 Run: `go test ./...`
    488 Expected: all pass
    489 
    490 **Step 5: Commit design doc**
    491 
    492 ```bash
    493 git add docs/plans/
    494 git commit -m "docs: add TUI improvements design and plan"
    495 ```