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 ```