esync

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

commit 64f64120403f612a0a5d950748fdb10d51a84308
parent 12134c63c97b4721f7f19f590054fbd410e93299
Author: Erik Loualiche <eloualic@umn.edu>
Date:   Wed,  4 Mar 2026 06:57:30 -0600

feat: cursor navigation with expand/collapse for dashboard events

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Minternal/tui/dashboard.go | 204+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Minternal/tui/styles.go | 1+
2 files changed, 185 insertions(+), 20 deletions(-)

diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go @@ -99,10 +99,23 @@ func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) { return m, nil } + // Shift expanded indices since we're prepending + newExpanded := make(map[int]bool, len(m.expanded)) + for idx, v := range m.expanded { + newExpanded[idx+1] = v + } + m.expanded = newExpanded + // Prepend event; cap at 500. m.events = append([]SyncEvent{evt}, m.events...) if len(m.events) > 500 { m.events = m.events[:500] + // Clean up expanded entries beyond 500 + for idx := range m.expanded { + if idx >= 500 { + delete(m.expanded, idx) + } + } } if evt.Status == "synced" { m.lastSync = evt.Time @@ -123,6 +136,9 @@ func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) { // updateNormal handles keys when NOT in filtering mode. func (m DashboardModel) updateNormal(msg tea.KeyMsg) (DashboardModel, tea.Cmd) { + filtered := m.filteredEvents() + maxCursor := max(0, len(filtered)-1) + switch msg.String() { case "q", "ctrl+c": return m, tea.Quit @@ -135,18 +151,36 @@ func (m DashboardModel) updateNormal(msg tea.KeyMsg) (DashboardModel, tea.Cmd) { case "r": return m, func() tea.Msg { return ResyncRequestMsg{} } case "j", "down": - filtered := m.filteredEvents() - maxOffset := max(0, len(filtered)-m.eventViewHeight()) - if m.offset < maxOffset { - m.offset++ + if m.cursor < maxCursor { + m.cursor++ } + m.ensureCursorVisible() case "k", "up": - if m.offset > 0 { - m.offset-- + if m.cursor > 0 { + m.cursor-- + } + m.ensureCursorVisible() + case "enter", "right": + if m.cursor < len(filtered) { + evt := filtered[m.cursor] + if len(evt.Files) > 0 { + idx := m.unfilteredIndex(m.cursor) + if idx >= 0 { + m.expanded[idx] = !m.expanded[idx] + } + } + } + case "left": + if m.cursor < len(filtered) { + idx := m.unfilteredIndex(m.cursor) + if idx >= 0 { + delete(m.expanded, idx) + } } case "/": m.filtering = true m.filter = "" + m.cursor = 0 m.offset = 0 } return m, nil @@ -200,14 +234,30 @@ func (m DashboardModel) View() string { filtered := m.filteredEvents() vh := m.eventViewHeight() - start := m.offset - end := min(start+vh, len(filtered)) - - for i := start; i < end; i++ { - b.WriteString(" " + m.renderEvent(filtered[i]) + "\n") + nw := m.nameWidth() + + // Render events from offset, counting visible lines including expanded children + linesRendered := 0 + for i := m.offset; i < len(filtered) && linesRendered < vh; i++ { + focused := i == m.cursor + b.WriteString(m.renderEvent(filtered[i], focused, nw) + "\n") + linesRendered++ + + // Render expanded children + idx := m.unfilteredIndex(i) + if idx >= 0 && m.expanded[idx] && len(filtered[i].Files) > 0 { + children := m.renderChildren(filtered[i].Files, nw) + for _, child := range children { + if linesRendered >= vh { + break + } + b.WriteString(child + "\n") + linesRendered++ + } + } } // Pad empty rows - for i := end - start; i < vh; i++ { + for i := linesRendered; i < vh; i++ { b.WriteString("\n") } @@ -220,7 +270,7 @@ func (m DashboardModel) View() string { if m.filtering { b.WriteString(helpStyle.Render(fmt.Sprintf(" filter: %s█ (enter apply esc clear)", m.filter))) } else { - help := " q quit p pause r resync ↑↓ scroll l logs / filter" + help := " q quit p pause r resync ↑↓ navigate enter expand l logs / filter" if m.filter != "" { help += fmt.Sprintf(" [filter: %s]", m.filter) } @@ -256,21 +306,34 @@ func (m DashboardModel) statusDisplay() (string, string) { } // renderEvent formats a single sync event line. -func (m DashboardModel) renderEvent(evt SyncEvent) string { +// nameWidth is the column width for the file name. +func (m DashboardModel) renderEvent(evt SyncEvent, focused bool, nameWidth int) string { ts := dimStyle.Render(evt.Time.Format("15:04:05")) + marker := " " + if focused { + marker = "> " + } + switch evt.Status { case "synced": - name := padRight(abbreviatePath(evt.File, 30), 30) + name := padRight(abbreviatePath(evt.File, nameWidth), nameWidth) + if focused { + name = focusedStyle.Render(name) + } detail := "" if evt.Size != "" { - detail = dimStyle.Render(fmt.Sprintf("%18s %s", evt.Size, evt.Duration.Truncate(100*time.Millisecond))) + detail = dimStyle.Render(fmt.Sprintf(" %s %s", evt.Size, evt.Duration.Truncate(100*time.Millisecond))) } - return ts + " " + statusSynced.Render("✓") + " " + name + detail + icon := statusSynced.Render("✓") + return marker + ts + " " + icon + " " + name + detail case "error": - name := padRight(abbreviatePath(evt.File, 30), 30) - return ts + " " + statusError.Render("✗") + " " + name + statusError.Render("error") + name := padRight(abbreviatePath(evt.File, nameWidth), nameWidth) + if focused { + name = focusedStyle.Render(name) + } + return marker + ts + " " + statusError.Render("✗") + " " + name + statusError.Render("error") default: - return ts + " " + evt.File + return marker + ts + " " + evt.File } } @@ -289,6 +352,107 @@ func (m DashboardModel) filteredEvents() []SyncEvent { return out } +// nameWidth returns the dynamic width for the file name column. +// Reserves space for: marker(2) + timestamp(8) + gap(2) + icon(1) + gap(1) + +// [name] + gap(2) + size/duration(~30) = ~46 fixed chars. +func (m DashboardModel) nameWidth() int { + w := m.width - 46 + if w < 30 { + w = 30 + } + if w > 60 { + w = 60 + } + return w +} + +// unfilteredIndex returns the index in m.events corresponding to the i-th +// item in the filtered event list, or -1 if out of range. +func (m DashboardModel) unfilteredIndex(filteredIdx int) int { + if m.filter == "" { + return filteredIdx + } + lf := strings.ToLower(m.filter) + count := 0 + for i, evt := range m.events { + if strings.Contains(strings.ToLower(evt.File), lf) { + if count == filteredIdx { + return i + } + count++ + } + } + return -1 +} + +// ensureCursorVisible adjusts offset so the cursor row is within the viewport. +func (m *DashboardModel) ensureCursorVisible() { + vh := m.eventViewHeight() + + // Scroll up if cursor is above viewport + if m.cursor < m.offset { + m.offset = m.cursor + return + } + + // Count visible lines from offset to cursor (inclusive), + // including expanded children. + filtered := m.filteredEvents() + lines := 0 + for i := m.offset; i <= m.cursor && i < len(filtered); i++ { + lines++ // the event row itself + idx := m.unfilteredIndex(i) + if idx >= 0 && m.expanded[idx] { + childCount := len(filtered[i].Files) + if childCount > maxExpandedFiles { + childCount = maxExpandedFiles + 1 // +1 for the "+N more" line + } + lines += childCount + } + } + + // Scroll down if cursor line is beyond viewport + for lines > vh && m.offset < m.cursor { + // Subtract lines for the row we scroll past + lines-- // the event row + idx := m.unfilteredIndex(m.offset) + if idx >= 0 && m.expanded[idx] { + childCount := len(filtered[m.offset].Files) + if childCount > maxExpandedFiles { + childCount = maxExpandedFiles + 1 + } + lines -= childCount + } + m.offset++ + } +} + +// maxExpandedFiles is the maximum number of child files shown when expanded. +const maxExpandedFiles = 10 + +// renderChildren renders the expanded file list for a directory group. +// Shows at most maxExpandedFiles entries, with a "+N more" line if truncated. +func (m DashboardModel) renderChildren(files []string, nameWidth int) []string { + // Prefix aligns under the parent name column: + // marker(2) + timestamp(8) + gap(2) + icon(1) + gap(1) = 14 chars + prefix := strings.Repeat(" ", 14) + show := files + truncated := 0 + if len(files) > maxExpandedFiles { + show = files[:maxExpandedFiles] + truncated = len(files) - maxExpandedFiles + } + var lines []string + for _, f := range show { + name := abbreviatePath(f, nameWidth-2) + lines = append(lines, prefix+"└ "+dimStyle.Render(name)) + } + if truncated > 0 { + lines = append(lines, prefix+dimStyle.Render(fmt.Sprintf(" +%d more", truncated))) + } + return lines +} + // abbreviatePath shortens a file path to fit within maxLen by replacing // leading directory segments with their first letter. // "internal/syncer/syncer.go" → "i/s/syncer.go" diff --git a/internal/tui/styles.go b/internal/tui/styles.go @@ -15,4 +15,5 @@ var ( statusError = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + focusedStyle = lipgloss.NewStyle().Bold(true) )