esync

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

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 }