esync

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

2026-03-03-cursor-expand-plan.md (15585B)


      1 # Cursor Navigation & Inline Expand Implementation Plan
      2 
      3 > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
      4 
      5 **Goal:** Add cursor-based navigation to the TUI dashboard event list with inline expand/collapse to reveal individual files inside directory groups.
      6 
      7 **Architecture:** Add a `Files []string` field to `SyncEvent` so grouped events carry their children. Add `cursor` and `expanded` state to `DashboardModel`. Render focused rows with a highlight marker and expanded children indented below. Use dynamic column widths based on terminal width.
      8 
      9 **Tech Stack:** Go, Bubbletea, Lipgloss (all already in use)
     10 
     11 ---
     12 
     13 ### Task 1: Add `Files` field to SyncEvent
     14 
     15 **Files:**
     16 - Modify: `internal/tui/dashboard.go:26-32`
     17 
     18 **Step 1: Add the field**
     19 
     20 In the `SyncEvent` struct, add a `Files` field after `Status`:
     21 
     22 ```go
     23 type SyncEvent struct {
     24 	File     string
     25 	Size     string
     26 	Duration time.Duration
     27 	Status   string // "synced", "syncing", "error"
     28 	Time     time.Time
     29 	Files    []string // individual file paths for directory groups
     30 }
     31 ```
     32 
     33 **Step 2: Build and verify**
     34 
     35 Run: `go build ./...`
     36 Expected: clean build, no errors (field is unused so far, which is fine)
     37 
     38 **Step 3: Commit**
     39 
     40 ```
     41 feat: add Files field to SyncEvent for directory group children
     42 ```
     43 
     44 ---
     45 
     46 ### Task 2: Populate `Files` when building grouped events
     47 
     48 **Files:**
     49 - Modify: `cmd/sync.go:402-451` (groupFilesByTopLevel and its caller)
     50 
     51 **Step 1: Add `files` field to `groupedEvent`**
     52 
     53 ```go
     54 type groupedEvent struct {
     55 	name  string   // "cmd/" or "main.go"
     56 	count int      // number of files (1 for root files)
     57 	bytes int64    // total bytes
     58 	files []string // individual file paths within the group
     59 }
     60 ```
     61 
     62 **Step 2: Collect file names in `groupFilesByTopLevel`**
     63 
     64 In the directory branch of the loop, append `f.Name` to the group's `files` slice. In the output loop, copy files for multi-file groups:
     65 
     66 ```go
     67 func groupFilesByTopLevel(files []syncer.FileEntry) []groupedEvent {
     68 	dirMap := make(map[string]*groupedEvent)
     69 	dirFirstFile := make(map[string]string)
     70 	var rootFiles []groupedEvent
     71 	var dirOrder []string
     72 
     73 	for _, f := range files {
     74 		parts := strings.SplitN(f.Name, "/", 2)
     75 		if len(parts) == 1 {
     76 			rootFiles = append(rootFiles, groupedEvent{
     77 				name:  f.Name,
     78 				count: 1,
     79 				bytes: f.Bytes,
     80 			})
     81 		} else {
     82 			dir := parts[0] + "/"
     83 			if g, ok := dirMap[dir]; ok {
     84 				g.count++
     85 				g.bytes += f.Bytes
     86 				g.files = append(g.files, f.Name)
     87 			} else {
     88 				dirMap[dir] = &groupedEvent{
     89 					name:  dir,
     90 					count: 1,
     91 					bytes: f.Bytes,
     92 					files: []string{f.Name},
     93 				}
     94 				dirFirstFile[dir] = f.Name
     95 				dirOrder = append(dirOrder, dir)
     96 			}
     97 		}
     98 	}
     99 
    100 	var out []groupedEvent
    101 	for _, dir := range dirOrder {
    102 		g := *dirMap[dir]
    103 		if g.count == 1 {
    104 			g.name = dirFirstFile[dir]
    105 			g.files = nil // no need to expand single files
    106 		}
    107 		out = append(out, g)
    108 	}
    109 	out = append(out, rootFiles...)
    110 	return out
    111 }
    112 ```
    113 
    114 **Step 3: Pass files into `SyncEvent` in the handler**
    115 
    116 In `runTUI` handler (around line 237), set the `Files` field:
    117 
    118 ```go
    119 for _, g := range groups {
    120 	file := g.name
    121 	bytes := g.bytes
    122 	if totalGroupBytes == 0 && result.BytesTotal > 0 && totalGroupFiles > 0 {
    123 		bytes = result.BytesTotal * int64(g.count) / int64(totalGroupFiles)
    124 	}
    125 	size := formatSize(bytes)
    126 	if g.count > 1 {
    127 		size = fmt.Sprintf("%d files  %s", g.count, formatSize(bytes))
    128 	}
    129 	syncCh <- tui.SyncEvent{
    130 		File:     file,
    131 		Size:     size,
    132 		Duration: result.Duration,
    133 		Status:   "synced",
    134 		Time:     now,
    135 		Files:    g.files,
    136 	}
    137 }
    138 ```
    139 
    140 **Step 4: Build and run tests**
    141 
    142 Run: `go build ./... && go test ./...`
    143 Expected: clean build, all tests pass
    144 
    145 **Step 5: Commit**
    146 
    147 ```
    148 feat: populate SyncEvent.Files with individual paths for directory groups
    149 ```
    150 
    151 ---
    152 
    153 ### Task 3: Add cursor and expanded state to DashboardModel
    154 
    155 **Files:**
    156 - Modify: `internal/tui/dashboard.go:34-46`
    157 
    158 **Step 1: Add cursor and expanded fields**
    159 
    160 ```go
    161 type DashboardModel struct {
    162 	local, remote string
    163 	status        string
    164 	lastSync      time.Time
    165 	events        []SyncEvent
    166 	totalSynced   int
    167 	totalErrors   int
    168 	width, height int
    169 	filter        string
    170 	filtering     bool
    171 	offset        int
    172 	cursor        int          // index into filtered events
    173 	expanded      map[int]bool // keyed by index in unfiltered events slice
    174 }
    175 ```
    176 
    177 **Step 2: Initialize expanded map in NewDashboard**
    178 
    179 ```go
    180 func NewDashboard(local, remote string) DashboardModel {
    181 	return DashboardModel{
    182 		local:    local,
    183 		remote:   remote,
    184 		status:   "watching",
    185 		expanded: make(map[int]bool),
    186 	}
    187 }
    188 ```
    189 
    190 **Step 3: Build**
    191 
    192 Run: `go build ./...`
    193 Expected: clean build
    194 
    195 **Step 4: Commit**
    196 
    197 ```
    198 feat: add cursor and expanded state to DashboardModel
    199 ```
    200 
    201 ---
    202 
    203 ### Task 4: Cursor navigation and expand/collapse key handling
    204 
    205 **Files:**
    206 - Modify: `internal/tui/dashboard.go` — `updateNormal` method (lines 121-149)
    207 
    208 **Step 1: Replace scroll-only navigation with cursor-based navigation**
    209 
    210 Replace the `updateNormal` method:
    211 
    212 ```go
    213 func (m DashboardModel) updateNormal(msg tea.KeyMsg) (DashboardModel, tea.Cmd) {
    214 	filtered := m.filteredEvents()
    215 	maxCursor := max(0, len(filtered)-1)
    216 
    217 	switch msg.String() {
    218 	case "q", "ctrl+c":
    219 		return m, tea.Quit
    220 	case "p":
    221 		if m.status == "paused" {
    222 			m.status = "watching"
    223 		} else {
    224 			m.status = "paused"
    225 		}
    226 	case "r":
    227 		return m, func() tea.Msg { return ResyncRequestMsg{} }
    228 	case "j", "down":
    229 		if m.cursor < maxCursor {
    230 			m.cursor++
    231 		}
    232 		m.ensureCursorVisible()
    233 	case "k", "up":
    234 		if m.cursor > 0 {
    235 			m.cursor--
    236 		}
    237 		m.ensureCursorVisible()
    238 	case "enter", "right":
    239 		if m.cursor < len(filtered) {
    240 			evt := filtered[m.cursor]
    241 			if len(evt.Files) > 0 {
    242 				idx := m.unfilteredIndex(m.cursor)
    243 				if idx >= 0 {
    244 					m.expanded[idx] = !m.expanded[idx]
    245 				}
    246 			}
    247 		}
    248 	case "left", "esc":
    249 		if m.cursor < len(filtered) {
    250 			idx := m.unfilteredIndex(m.cursor)
    251 			if idx >= 0 {
    252 				delete(m.expanded, idx)
    253 			}
    254 		}
    255 	case "/":
    256 		m.filtering = true
    257 		m.filter = ""
    258 		m.cursor = 0
    259 		m.offset = 0
    260 	}
    261 	return m, nil
    262 }
    263 ```
    264 
    265 **Step 2: Add `unfilteredIndex` helper**
    266 
    267 This maps a filtered-list index back to the index in `m.events`:
    268 
    269 ```go
    270 // unfilteredIndex returns the index in m.events corresponding to the i-th
    271 // item in the filtered event list, or -1 if out of range.
    272 func (m DashboardModel) unfilteredIndex(filteredIdx int) int {
    273 	if m.filter == "" {
    274 		return filteredIdx
    275 	}
    276 	lf := strings.ToLower(m.filter)
    277 	count := 0
    278 	for i, evt := range m.events {
    279 		if strings.Contains(strings.ToLower(evt.File), lf) {
    280 			if count == filteredIdx {
    281 				return i
    282 			}
    283 			count++
    284 		}
    285 	}
    286 	return -1
    287 }
    288 ```
    289 
    290 **Step 3: Add `ensureCursorVisible` helper**
    291 
    292 This adjusts `offset` so the cursor row (plus any expanded children above it) stays in view:
    293 
    294 ```go
    295 // ensureCursorVisible adjusts offset so the cursor row is within the viewport.
    296 func (m *DashboardModel) ensureCursorVisible() {
    297 	vh := m.eventViewHeight()
    298 	// Count visible lines up to and including cursor
    299 	visibleLine := 0
    300 	filtered := m.filteredEvents()
    301 	for i := 0; i <= m.cursor && i < len(filtered); i++ {
    302 		if i >= m.offset {
    303 			visibleLine++
    304 		}
    305 		idx := m.unfilteredIndex(i)
    306 		if idx >= 0 && m.expanded[idx] {
    307 			if i >= m.offset {
    308 				visibleLine += len(filtered[i].Files)
    309 			}
    310 		}
    311 	}
    312 	// Scroll down if cursor is below viewport
    313 	for visibleLine > vh && m.offset < m.cursor {
    314 		// Subtract lines for the row we're scrolling past
    315 		old := m.offset
    316 		m.offset++
    317 		visibleLine--
    318 		oldIdx := m.unfilteredIndex(old)
    319 		if oldIdx >= 0 && m.expanded[oldIdx] {
    320 			visibleLine -= len(filtered[old].Files)
    321 		}
    322 	}
    323 	// Scroll up if cursor is above viewport
    324 	if m.cursor < m.offset {
    325 		m.offset = m.cursor
    326 	}
    327 }
    328 ```
    329 
    330 **Step 4: Clamp cursor when new events arrive**
    331 
    332 In the `SyncEventMsg` handler in `Update` (around line 88-109), after prepending the event, shift expanded indices and keep cursor valid:
    333 
    334 ```go
    335 case SyncEventMsg:
    336 	evt := SyncEvent(msg)
    337 
    338 	if strings.HasPrefix(evt.Status, "status:") {
    339 		m.status = strings.TrimPrefix(evt.Status, "status:")
    340 		return m, nil
    341 	}
    342 
    343 	// Shift expanded indices since we're prepending
    344 	newExpanded := make(map[int]bool, len(m.expanded))
    345 	for idx, v := range m.expanded {
    346 		newExpanded[idx+1] = v
    347 	}
    348 	m.expanded = newExpanded
    349 
    350 	m.events = append([]SyncEvent{evt}, m.events...)
    351 	if len(m.events) > 500 {
    352 		m.events = m.events[:500]
    353 		// Clean up expanded entries beyond 500
    354 		for idx := range m.expanded {
    355 			if idx >= 500 {
    356 				delete(m.expanded, idx)
    357 			}
    358 		}
    359 	}
    360 	if evt.Status == "synced" {
    361 		m.lastSync = evt.Time
    362 		m.totalSynced++
    363 	} else if evt.Status == "error" {
    364 		m.totalErrors++
    365 	}
    366 	return m, nil
    367 ```
    368 
    369 **Step 5: Build**
    370 
    371 Run: `go build ./...`
    372 Expected: clean build
    373 
    374 **Step 6: Commit**
    375 
    376 ```
    377 feat: cursor navigation with expand/collapse for dashboard events
    378 ```
    379 
    380 ---
    381 
    382 ### Task 5: Render focused row and expanded children with aligned columns
    383 
    384 **Files:**
    385 - Modify: `internal/tui/dashboard.go` — `View`, `renderEvent`, `eventViewHeight` methods
    386 - Modify: `internal/tui/styles.go` — add focused style
    387 
    388 **Step 1: Add focused style to styles.go**
    389 
    390 ```go
    391 var (
    392 	titleStyle    = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
    393 	statusSynced  = lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
    394 	statusSyncing = lipgloss.NewStyle().Foreground(lipgloss.Color("11"))
    395 	statusError   = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
    396 	dimStyle      = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
    397 	helpStyle     = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
    398 	focusedStyle  = lipgloss.NewStyle().Bold(true)
    399 )
    400 ```
    401 
    402 **Step 2: Update `renderEvent` to accept focus flag and use dynamic name width**
    403 
    404 Replace the `renderEvent` method:
    405 
    406 ```go
    407 // renderEvent formats a single sync event line.
    408 // nameWidth is the column width for the file name.
    409 func (m DashboardModel) renderEvent(evt SyncEvent, focused bool, nameWidth int) string {
    410 	ts := dimStyle.Render(evt.Time.Format("15:04:05"))
    411 	marker := "  "
    412 	if focused {
    413 		marker = "> "
    414 	}
    415 
    416 	switch evt.Status {
    417 	case "synced":
    418 		name := padRight(abbreviatePath(evt.File, nameWidth), nameWidth)
    419 		if focused {
    420 			name = focusedStyle.Render(name)
    421 		}
    422 		detail := ""
    423 		if evt.Size != "" {
    424 			detail = dimStyle.Render(fmt.Sprintf("  %s  %s", evt.Size, evt.Duration.Truncate(100*time.Millisecond)))
    425 		}
    426 		icon := statusSynced.Render("✓")
    427 		return marker + ts + "  " + icon + " " + name + detail
    428 	case "error":
    429 		name := padRight(abbreviatePath(evt.File, nameWidth), nameWidth)
    430 		if focused {
    431 			name = focusedStyle.Render(name)
    432 		}
    433 		return marker + ts + "  " + statusError.Render("✗") + " " + name + statusError.Render("error")
    434 	default:
    435 		return marker + ts + "  " + evt.File
    436 	}
    437 }
    438 ```
    439 
    440 **Step 3: Add `renderChildren` method**
    441 
    442 ```go
    443 // renderChildren renders the expanded file list for a directory group.
    444 func (m DashboardModel) renderChildren(files []string, nameWidth int) []string {
    445 	var lines []string
    446 	for _, f := range files {
    447 		// Indent to align under the parent name column:
    448 		// "  " (marker) + "HH:MM:SS" (8) + "  " (2) + icon (1) + " " (1) = 14 chars prefix
    449 		prefix := strings.Repeat(" ", 14)
    450 		name := abbreviatePath(f, nameWidth-2)
    451 		lines = append(lines, prefix+"└ "+dimStyle.Render(name))
    452 	}
    453 	return lines
    454 }
    455 ```
    456 
    457 **Step 4: Update `nameWidth` helper**
    458 
    459 ```go
    460 // nameWidth returns the dynamic width for the file name column based on
    461 // terminal width. Reserves space for: marker(2) + timestamp(8) + gap(2) +
    462 // icon(1) + gap(1) + [name] + gap(2) + size/duration(~30) = ~46 fixed.
    463 func (m DashboardModel) nameWidth() int {
    464 	w := m.width - 46
    465 	if w < 30 {
    466 		w = 30
    467 	}
    468 	if w > 60 {
    469 		w = 60
    470 	}
    471 	return w
    472 }
    473 ```
    474 
    475 **Step 5: Update `View` to render cursor and expanded children**
    476 
    477 Replace the event rendering loop in `View`:
    478 
    479 ```go
    480 // --- Recent events ---
    481 b.WriteString("  " + titleStyle.Render("Recent") + " " + dimStyle.Render(strings.Repeat("─", max(0, m.width-11))) + "\n")
    482 
    483 filtered := m.filteredEvents()
    484 vh := m.eventViewHeight()
    485 nw := m.nameWidth()
    486 
    487 // Render events from offset, counting visible lines including expanded children
    488 linesRendered := 0
    489 for i := m.offset; i < len(filtered) && linesRendered < vh; i++ {
    490 	focused := i == m.cursor
    491 	b.WriteString(m.renderEvent(filtered[i], focused, nw) + "\n")
    492 	linesRendered++
    493 
    494 	// Render expanded children
    495 	idx := m.unfilteredIndex(i)
    496 	if idx >= 0 && m.expanded[idx] && len(filtered[i].Files) > 0 {
    497 		children := m.renderChildren(filtered[i].Files, nw)
    498 		for _, child := range children {
    499 			if linesRendered >= vh {
    500 				break
    501 			}
    502 			b.WriteString(child + "\n")
    503 			linesRendered++
    504 		}
    505 	}
    506 }
    507 // Pad empty rows
    508 for i := linesRendered; i < vh; i++ {
    509 	b.WriteString("\n")
    510 }
    511 ```
    512 
    513 **Step 6: Update `eventViewHeight`**
    514 
    515 The fixed layout adds 2 chars for the marker prefix per row. The header/stats/help line count stays the same (8 lines). No change needed to the calculation — it still returns `m.height - 8`.
    516 
    517 **Step 7: Update the help line**
    518 
    519 Replace the help text in the non-filtering branch:
    520 
    521 ```go
    522 help := "  q quit  p pause  r resync  ↑↓ navigate  enter expand  l logs  / filter"
    523 ```
    524 
    525 **Step 8: Build and test manually**
    526 
    527 Run: `go build ./... && go test ./...`
    528 Expected: clean build, all tests pass
    529 
    530 **Step 9: Commit**
    531 
    532 ```
    533 feat: render focused row highlight and inline expanded children with aligned columns
    534 ```
    535 
    536 ---
    537 
    538 ### Task 6: Test the groupFilesByTopLevel change
    539 
    540 **Files:**
    541 - Create: `cmd/sync_test.go`
    542 
    543 **Step 1: Write test for grouping with files populated**
    544 
    545 ```go
    546 package cmd
    547 
    548 import (
    549 	"testing"
    550 
    551 	"github.com/louloulibs/esync/internal/syncer"
    552 )
    553 
    554 func TestGroupFilesByTopLevel_MultiFile(t *testing.T) {
    555 	files := []syncer.FileEntry{
    556 		{Name: "cmd/sync.go", Bytes: 100},
    557 		{Name: "cmd/root.go", Bytes: 200},
    558 		{Name: "main.go", Bytes: 50},
    559 	}
    560 
    561 	groups := groupFilesByTopLevel(files)
    562 
    563 	if len(groups) != 2 {
    564 		t.Fatalf("got %d groups, want 2", len(groups))
    565 	}
    566 
    567 	// First group: cmd/ with 2 files
    568 	g := groups[0]
    569 	if g.name != "cmd/" {
    570 		t.Errorf("group[0].name = %q, want %q", g.name, "cmd/")
    571 	}
    572 	if g.count != 2 {
    573 		t.Errorf("group[0].count = %d, want 2", g.count)
    574 	}
    575 	if len(g.files) != 2 {
    576 		t.Fatalf("group[0].files has %d entries, want 2", len(g.files))
    577 	}
    578 	if g.files[0] != "cmd/sync.go" || g.files[1] != "cmd/root.go" {
    579 		t.Errorf("group[0].files = %v, want [cmd/sync.go cmd/root.go]", g.files)
    580 	}
    581 
    582 	// Second group: root file
    583 	g = groups[1]
    584 	if g.name != "main.go" {
    585 		t.Errorf("group[1].name = %q, want %q", g.name, "main.go")
    586 	}
    587 	if g.files != nil {
    588 		t.Errorf("group[1].files should be nil for root file, got %v", g.files)
    589 	}
    590 }
    591 
    592 func TestGroupFilesByTopLevel_SingleFileDir(t *testing.T) {
    593 	files := []syncer.FileEntry{
    594 		{Name: "internal/config/config.go", Bytes: 300},
    595 	}
    596 
    597 	groups := groupFilesByTopLevel(files)
    598 
    599 	if len(groups) != 1 {
    600 		t.Fatalf("got %d groups, want 1", len(groups))
    601 	}
    602 
    603 	g := groups[0]
    604 	// Single-file dir uses full path
    605 	if g.name != "internal/config/config.go" {
    606 		t.Errorf("name = %q, want full path", g.name)
    607 	}
    608 	// No files for single-file groups
    609 	if g.files != nil {
    610 		t.Errorf("files should be nil for single-file dir, got %v", g.files)
    611 	}
    612 }
    613 ```
    614 
    615 **Step 2: Run tests**
    616 
    617 Run: `go test ./cmd/ -run TestGroupFilesByTopLevel -v`
    618 Expected: both tests pass
    619 
    620 **Step 3: Commit**
    621 
    622 ```
    623 test: add tests for groupFilesByTopLevel with files field
    624 ```
    625 
    626 ---
    627 
    628 ### Task 7: Final build and integration check
    629 
    630 **Step 1: Full build and test suite**
    631 
    632 Run: `go build ./... && go test ./...`
    633 Expected: all pass
    634 
    635 **Step 2: Build release binary**
    636 
    637 Run: `GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o esync-darwin-arm64 .`
    638 Expected: binary produced
    639 
    640 **Step 3: Commit**
    641 
    642 ```
    643 chore: verify build after cursor navigation feature
    644 ```