dashboard.go (17070B)
1 package tui 2 3 import ( 4 "fmt" 5 "path/filepath" 6 "strings" 7 "time" 8 9 tea "github.com/charmbracelet/bubbletea" 10 ) 11 12 // --------------------------------------------------------------------------- 13 // Messages 14 // --------------------------------------------------------------------------- 15 16 // tickMsg is sent on every one-second tick for periodic refresh. 17 type tickMsg time.Time 18 19 // SyncEventMsg carries a single sync event into the TUI. 20 type SyncEventMsg SyncEvent 21 22 // --------------------------------------------------------------------------- 23 // Types 24 // --------------------------------------------------------------------------- 25 26 // SyncEvent represents a single file sync operation. 27 type SyncEvent struct { 28 File string 29 Size string 30 Duration time.Duration 31 Status string // "synced", "syncing", "error" 32 Time time.Time 33 Files []string // individual file paths for directory groups (max 10) 34 FileCount int // total file count in group (may exceed len(Files)) 35 } 36 37 // DashboardModel is the main TUI view showing sync status and recent events. 38 type DashboardModel struct { 39 local, remote string 40 status string // "watching", "syncing", "paused", "error" 41 lastSync time.Time 42 events []SyncEvent 43 totalSynced int 44 totalErrors int 45 totalWarnings int 46 width, height int 47 filter string 48 filtering bool 49 offset int 50 cursor int // index into filtered events 51 childCursor int // -1 = on parent row, >=0 = index into expanded Files 52 expanded map[int]bool // keyed by index in unfiltered events slice 53 } 54 55 // --------------------------------------------------------------------------- 56 // Constructor 57 // --------------------------------------------------------------------------- 58 59 // NewDashboard returns a DashboardModel configured with the given local and 60 // remote paths. 61 func NewDashboard(local, remote string) DashboardModel { 62 return DashboardModel{ 63 local: local, 64 remote: remote, 65 status: "watching", 66 childCursor: -1, 67 expanded: make(map[int]bool), 68 } 69 } 70 71 // --------------------------------------------------------------------------- 72 // tea.Model interface 73 // --------------------------------------------------------------------------- 74 75 // Init starts the periodic tick timer. 76 func (m DashboardModel) Init() tea.Cmd { 77 return tea.Tick(time.Second, func(t time.Time) tea.Msg { 78 return tickMsg(t) 79 }) 80 } 81 82 // Update handles messages for the dashboard view. 83 func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) { 84 switch msg := msg.(type) { 85 86 case tea.KeyMsg: 87 if m.filtering { 88 return m.updateFiltering(msg) 89 } 90 return m.updateNormal(msg) 91 92 case tickMsg: 93 // Re-arm the ticker. 94 return m, tea.Tick(time.Second, func(t time.Time) tea.Msg { 95 return tickMsg(t) 96 }) 97 98 case SyncEventMsg: 99 evt := SyncEvent(msg) 100 101 // Status-only messages update the header, not the event list 102 if strings.HasPrefix(evt.Status, "status:") { 103 m.status = strings.TrimPrefix(evt.Status, "status:") 104 return m, nil 105 } 106 107 // Shift expanded indices since we're prepending 108 newExpanded := make(map[int]bool, len(m.expanded)) 109 for idx, v := range m.expanded { 110 newExpanded[idx+1] = v 111 } 112 m.expanded = newExpanded 113 m.childCursor = -1 114 115 // Prepend event; cap at 500. 116 m.events = append([]SyncEvent{evt}, m.events...) 117 if len(m.events) > 500 { 118 m.events = m.events[:500] 119 // Clean up expanded entries beyond 500 120 for idx := range m.expanded { 121 if idx >= 500 { 122 delete(m.expanded, idx) 123 } 124 } 125 } 126 if evt.Status == "synced" { 127 m.lastSync = evt.Time 128 m.totalSynced++ 129 } else if evt.Status == "error" { 130 m.totalErrors++ 131 } 132 return m, nil 133 134 case tea.WindowSizeMsg: 135 m.width = msg.Width 136 m.height = msg.Height 137 return m, nil 138 } 139 140 return m, nil 141 } 142 143 // updateNormal handles keys when NOT in filtering mode. 144 func (m DashboardModel) updateNormal(msg tea.KeyMsg) (DashboardModel, tea.Cmd) { 145 filtered := m.filteredEvents() 146 147 switch msg.String() { 148 case "q", "ctrl+c": 149 return m, tea.Quit 150 case "p": 151 if m.status == "paused" { 152 m.status = "watching" 153 } else { 154 m.status = "paused" 155 } 156 case "r": 157 return m, func() tea.Msg { return ResyncRequestMsg{} } 158 case "j", "down": 159 m.moveDown(filtered) 160 m.ensureCursorVisible() 161 case "k", "up": 162 m.moveUp(filtered) 163 m.ensureCursorVisible() 164 case "enter", "right": 165 if m.cursor < len(filtered) { 166 evt := filtered[m.cursor] 167 if len(evt.Files) > 0 { 168 idx := m.unfilteredIndex(m.cursor) 169 if idx >= 0 { 170 m.expanded[idx] = !m.expanded[idx] 171 m.childCursor = -1 172 } 173 } 174 } 175 case "left": 176 if m.cursor < len(filtered) { 177 idx := m.unfilteredIndex(m.cursor) 178 if idx >= 0 { 179 delete(m.expanded, idx) 180 m.childCursor = -1 181 } 182 } 183 case "v": 184 if m.cursor >= len(filtered) { 185 break 186 } 187 evt := filtered[m.cursor] 188 idx := m.unfilteredIndex(m.cursor) 189 190 // On a child file — open it 191 if m.childCursor >= 0 && m.childCursor < len(evt.Files) { 192 path := filepath.Join(m.local, evt.Files[m.childCursor]) 193 return m, func() tea.Msg { return OpenFileMsg{Path: path} } 194 } 195 196 // On a parent with children — expand (same as enter) 197 if len(evt.Files) > 0 { 198 if idx >= 0 && !m.expanded[idx] { 199 m.expanded[idx] = true 200 return m, nil 201 } 202 // Already expanded but cursor on parent — do nothing 203 return m, nil 204 } 205 206 // Single-file event — open it 207 path := filepath.Join(m.local, evt.File) 208 return m, func() tea.Msg { return OpenFileMsg{Path: path} } 209 case "e": 210 return m, func() tea.Msg { return EditConfigMsg{} } 211 case "E": 212 return m, func() tea.Msg { return EditConfigMsg{Visual: true} } 213 case "/": 214 m.filtering = true 215 m.filter = "" 216 m.cursor = 0 217 m.offset = 0 218 m.childCursor = -1 219 } 220 return m, nil 221 } 222 223 // updateFiltering handles keys when in filtering mode. 224 func (m DashboardModel) updateFiltering(msg tea.KeyMsg) (DashboardModel, tea.Cmd) { 225 switch msg.Type { 226 case tea.KeyEnter: 227 m.filtering = false 228 case tea.KeyEscape: 229 m.filter = "" 230 m.filtering = false 231 case tea.KeyBackspace: 232 if len(m.filter) > 0 { 233 m.filter = m.filter[:len(m.filter)-1] 234 } 235 default: 236 if len(msg.String()) == 1 { 237 m.filter += msg.String() 238 } 239 } 240 return m, nil 241 } 242 243 // moveDown advances cursor one visual row, entering expanded children. 244 func (m *DashboardModel) moveDown(filtered []SyncEvent) { 245 if m.cursor >= len(filtered) { 246 return 247 } 248 idx := m.unfilteredIndex(m.cursor) 249 evt := filtered[m.cursor] 250 251 // Currently on parent of expanded event — enter children 252 if m.childCursor == -1 && idx >= 0 && m.expanded[idx] && len(evt.Files) > 0 { 253 m.childCursor = 0 254 return 255 } 256 257 // Currently on a child — advance within children 258 if m.childCursor >= 0 { 259 if m.childCursor < len(evt.Files)-1 { 260 m.childCursor++ 261 return 262 } 263 // Past last child — move to next event 264 if m.cursor < len(filtered)-1 { 265 m.cursor++ 266 m.childCursor = -1 267 } 268 return 269 } 270 271 // Normal: move to next event 272 if m.cursor < len(filtered)-1 { 273 m.cursor++ 274 m.childCursor = -1 275 } 276 } 277 278 // moveUp moves cursor one visual row, entering expanded children from bottom. 279 func (m *DashboardModel) moveUp(filtered []SyncEvent) { 280 // Currently on a child — move up within children 281 if m.childCursor > 0 { 282 m.childCursor-- 283 return 284 } 285 286 // On first child — move back to parent 287 if m.childCursor == 0 { 288 m.childCursor = -1 289 return 290 } 291 292 // On a parent row — move to previous event 293 if m.cursor <= 0 { 294 return 295 } 296 m.cursor-- 297 m.childCursor = -1 298 299 // If previous event is expanded, land on its last child 300 prevIdx := m.unfilteredIndex(m.cursor) 301 prevEvt := filtered[m.cursor] 302 if prevIdx >= 0 && m.expanded[prevIdx] && len(prevEvt.Files) > 0 { 303 m.childCursor = len(prevEvt.Files) - 1 304 } 305 } 306 307 // eventViewHeight returns the number of event rows that fit in the terminal. 308 // Layout: header (3 lines) + "Recent" header (1) + stats section (3) + help (1) = 8 fixed. 309 func (m DashboardModel) eventViewHeight() int { 310 return max(1, m.height-8) 311 } 312 313 // View renders the dashboard. 314 func (m DashboardModel) View() string { 315 var b strings.Builder 316 317 // --- Header (3 lines) --- 318 header := titleStyle.Render(" esync ") + dimStyle.Render(strings.Repeat("─", max(0, m.width-8))) 319 b.WriteString(header + "\n") 320 b.WriteString(fmt.Sprintf(" %s → %s\n", m.local, m.remote)) 321 322 statusIcon, statusText := m.statusDisplay() 323 agoText := "" 324 if !m.lastSync.IsZero() { 325 ago := time.Since(m.lastSync).Truncate(time.Second) 326 agoText = fmt.Sprintf(" (synced %s ago)", ago) 327 } 328 b.WriteString(fmt.Sprintf(" %s %s%s\n", statusIcon, statusText, dimStyle.Render(agoText))) 329 330 // --- Recent events --- 331 b.WriteString(" " + titleStyle.Render("Recent") + " " + dimStyle.Render(strings.Repeat("─", max(0, m.width-11))) + "\n") 332 333 filtered := m.filteredEvents() 334 vh := m.eventViewHeight() 335 nw := m.nameWidth() 336 337 // Render events from offset, counting visible lines including expanded children 338 linesRendered := 0 339 for i := m.offset; i < len(filtered) && linesRendered < vh; i++ { 340 focused := i == m.cursor 341 b.WriteString(m.renderEvent(filtered[i], focused, nw) + "\n") 342 linesRendered++ 343 344 // Render expanded children 345 idx := m.unfilteredIndex(i) 346 if idx >= 0 && m.expanded[idx] && len(filtered[i].Files) > 0 { 347 focusedChild := -1 348 if i == m.cursor { 349 focusedChild = m.childCursor 350 } 351 children := m.renderChildren(filtered[i].Files, filtered[i].FileCount, nw, focusedChild) 352 for _, child := range children { 353 if linesRendered >= vh { 354 break 355 } 356 b.WriteString(child + "\n") 357 linesRendered++ 358 } 359 } 360 } 361 // Pad empty rows 362 for i := linesRendered; i < vh; i++ { 363 b.WriteString("\n") 364 } 365 366 // --- Stats (2 lines) --- 367 b.WriteString(" " + titleStyle.Render("Stats") + " " + dimStyle.Render(strings.Repeat("─", max(0, m.width-10))) + "\n") 368 stats := fmt.Sprintf(" %d syncs │ %d errors", m.totalSynced, m.totalErrors) 369 if m.totalWarnings > 0 { 370 stats += fmt.Sprintf(" │ %d warnings", m.totalWarnings) 371 } 372 b.WriteString(stats + "\n") 373 374 // --- Help (1 line) --- 375 if m.filtering { 376 b.WriteString(helpStyle.Render(fmt.Sprintf(" filter: %s█ (enter apply esc clear)", m.filter))) 377 } else { 378 help := " " + 379 helpKey("q") + helpDesc("quit") + 380 helpKey("p") + helpDesc("pause") + 381 helpKey("r") + helpDesc("resync") + 382 helpKey("↑↓") + helpDesc("navigate") + 383 helpKey("enter") + helpDesc("expand") + 384 helpKey("v") + helpDesc("view") + 385 helpKey("e/E") + helpDesc("config") + 386 helpKey("l") + helpDesc("logs") + 387 helpKey("/") + helpDesc("filter") 388 if m.filter != "" { 389 help += dimStyle.Render(fmt.Sprintf(" [filter: %s]", m.filter)) 390 } 391 b.WriteString(help) 392 } 393 b.WriteString("\n") 394 395 return b.String() 396 } 397 398 // --------------------------------------------------------------------------- 399 // Helpers 400 // --------------------------------------------------------------------------- 401 402 // statusDisplay returns the icon and styled text for the current status. 403 func (m DashboardModel) statusDisplay() (string, string) { 404 switch { 405 case m.status == "watching": 406 return statusSynced.Render("●"), statusSynced.Render("Watching") 407 case strings.HasPrefix(m.status, "syncing"): 408 label := "Syncing" 409 if pct := strings.TrimPrefix(m.status, "syncing "); pct != m.status { 410 label = "Syncing " + pct 411 } 412 return statusSyncing.Render("⟳"), statusSyncing.Render(label) 413 case m.status == "paused": 414 return dimStyle.Render("⏸"), dimStyle.Render("Paused") 415 case m.status == "error": 416 return statusError.Render("✗"), statusError.Render("Error") 417 default: 418 return "?", m.status 419 } 420 } 421 422 // renderEvent formats a single sync event line. 423 // nameWidth is the column width for the file name. 424 func (m DashboardModel) renderEvent(evt SyncEvent, focused bool, nameWidth int) string { 425 ts := dimStyle.Render(evt.Time.Format("15:04:05")) 426 marker := " " 427 if focused { 428 marker = "> " 429 } 430 431 switch evt.Status { 432 case "synced": 433 name := padRight(abbreviatePath(evt.File, nameWidth), nameWidth) 434 if focused { 435 name = focusedStyle.Render(name) 436 } 437 detail := "" 438 if evt.Size != "" { 439 dur := fmt.Sprintf("%s", evt.Duration.Truncate(100*time.Millisecond)) 440 count := "" 441 if evt.FileCount > 1 { 442 count = fmt.Sprintf("%d files", evt.FileCount) 443 } 444 detail = dimStyle.Render(fmt.Sprintf("%8s %7s %5s", count, evt.Size, dur)) 445 } 446 icon := statusSynced.Render("✓") 447 return marker + ts + " " + icon + " " + name + detail 448 case "error": 449 name := padRight(abbreviatePath(evt.File, nameWidth), nameWidth) 450 if focused { 451 name = focusedStyle.Render(name) 452 } 453 return marker + ts + " " + statusError.Render("✗") + " " + name + statusError.Render("error") 454 default: 455 return marker + ts + " " + evt.File 456 } 457 } 458 459 // filteredEvents returns events matching the current filter (case-insensitive). 460 func (m DashboardModel) filteredEvents() []SyncEvent { 461 if m.filter == "" { 462 return m.events 463 } 464 lf := strings.ToLower(m.filter) 465 var out []SyncEvent 466 for _, evt := range m.events { 467 if strings.Contains(strings.ToLower(evt.File), lf) { 468 out = append(out, evt) 469 } 470 } 471 return out 472 } 473 474 // nameWidth returns the dynamic width for the file name column. 475 // Reserves space for: marker(2) + timestamp(8) + gap(2) + icon(1) + gap(1) + 476 // [name] + gap(2) + size/duration(~30) = ~46 fixed chars. 477 func (m DashboardModel) nameWidth() int { 478 w := m.width - 46 479 if w < 30 { 480 w = 30 481 } 482 if w > 60 { 483 w = 60 484 } 485 return w 486 } 487 488 // unfilteredIndex returns the index in m.events corresponding to the i-th 489 // item in the filtered event list, or -1 if out of range. 490 func (m DashboardModel) unfilteredIndex(filteredIdx int) int { 491 if m.filter == "" { 492 return filteredIdx 493 } 494 lf := strings.ToLower(m.filter) 495 count := 0 496 for i, evt := range m.events { 497 if strings.Contains(strings.ToLower(evt.File), lf) { 498 if count == filteredIdx { 499 return i 500 } 501 count++ 502 } 503 } 504 return -1 505 } 506 507 // ensureCursorVisible adjusts offset so the cursor row is within the viewport. 508 func (m *DashboardModel) ensureCursorVisible() { 509 vh := m.eventViewHeight() 510 511 // Scroll up if cursor is above viewport 512 if m.cursor < m.offset { 513 m.offset = m.cursor 514 return 515 } 516 517 // Count visible lines from offset to cursor (inclusive), 518 // including expanded children. 519 filtered := m.filteredEvents() 520 lines := 0 521 for i := m.offset; i <= m.cursor && i < len(filtered); i++ { 522 lines++ // the event row itself 523 idx := m.unfilteredIndex(i) 524 if idx >= 0 && m.expanded[idx] { 525 if i == m.cursor && m.childCursor >= 0 { 526 // Only count up to the focused child 527 lines += m.childCursor + 1 528 } else { 529 lines += expandedLineCount(filtered[i]) 530 } 531 } 532 } 533 534 // Scroll down if cursor line is beyond viewport 535 for lines > vh && m.offset < m.cursor { 536 // Subtract lines for the row we scroll past 537 lines-- // the event row 538 idx := m.unfilteredIndex(m.offset) 539 if idx >= 0 && m.expanded[idx] { 540 lines -= expandedLineCount(filtered[m.offset]) 541 } 542 m.offset++ 543 } 544 } 545 546 // expandedLineCount returns the number of child lines rendered for an event: 547 // one per stored file, plus a "+N more" line if FileCount exceeds len(Files). 548 func expandedLineCount(evt SyncEvent) int { 549 n := len(evt.Files) 550 if evt.FileCount > n { 551 n++ // the "+N more" line 552 } 553 return n 554 } 555 556 // renderChildren renders the expanded file list for a directory group. 557 // totalCount is the original number of files in the group (may exceed len(files)). 558 // focusedChild is the index of the focused child (-1 if none). 559 func (m DashboardModel) renderChildren(files []string, totalCount int, nameWidth int, focusedChild int) []string { 560 // Prefix aligns under the parent name column: 561 // marker(2) + timestamp(8) + gap(2) + icon(1) + gap(1) = 14 chars 562 prefix := strings.Repeat(" ", 14) 563 var lines []string 564 for i, f := range files { 565 name := abbreviatePath(f, nameWidth-2) 566 if i == focusedChild { 567 lines = append(lines, prefix+"> "+focusedStyle.Render(name)) 568 } else { 569 lines = append(lines, prefix+" "+dimStyle.Render(name)) 570 } 571 } 572 if remaining := totalCount - len(files); remaining > 0 { 573 lines = append(lines, prefix+dimStyle.Render(fmt.Sprintf(" +%d more", remaining))) 574 } 575 return lines 576 } 577 578 // abbreviatePath shortens a file path to fit within maxLen by replacing 579 // leading directory segments with their first letter. 580 // "internal/syncer/syncer.go" → "i/s/syncer.go" 581 func abbreviatePath(p string, maxLen int) string { 582 if len(p) <= maxLen { 583 return p 584 } 585 parts := strings.Split(p, "/") 586 if len(parts) <= 1 { 587 return p 588 } 589 // Shorten directory segments from the left, keep the filename intact. 590 for i := 0; i < len(parts)-1; i++ { 591 if len(parts[i]) > 1 { 592 parts[i] = parts[i][:1] 593 } 594 if len(strings.Join(parts, "/")) <= maxLen { 595 break 596 } 597 } 598 return strings.Join(parts, "/") 599 } 600 601 // helpKey renders a keyboard shortcut key in normal (bright) style. 602 func helpKey(k string) string { return helpKeyStyle.Render(k) + " " } 603 604 // helpDesc renders a shortcut description in dim style with spacing. 605 func helpDesc(d string) string { return dimStyle.Render(d) + " " } 606 607 // padRight pads s with spaces to width n, truncating if necessary. 608 func padRight(s string, n int) string { 609 if len(s) >= n { 610 return s[:n] 611 } 612 return s + strings.Repeat(" ", n-len(s)) 613 }