esync

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

commit 4c56b80bafe0999f2f14de8a78667ff0d3cdd825
parent 2639d4d30fda9cf3270e836a20d6b45569aea1a3
Author: Erik Loualiche <[email protected]>
Date:   Sun,  1 Mar 2026 16:00:25 -0600

feat(tui): timestamps, scrolling, top-level grouping, resync key (#7)

- Remove "syncing" events from event list; use transient header status
  (⟳ Syncing / ● Watching) instead
- Group synced files by top-level directory (cmd/sync.go + cmd/init.go
  become one "cmd/" entry with file count and total size)
- Add HH:MM:SS timestamps to every event row
- Event list fills terminal height dynamically and pads empty rows
- Add j/k and ↑/↓ scrolling through event history
- Wire up r key to trigger a full resync via channel
- Accumulate stats (event count, error count) from event stream
- Remove unused SyncStatsMsg type

Co-authored-by: Claude Opus 4.6 <[email protected]>
Diffstat:
Mcmd/sync.go | 83++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Adocs/plans/2026-03-01-tui-improvements-design.md | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/plans/2026-03-01-tui-improvements-plan.md | 495+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/tui/app.go | 24++++++++++++++++++++++++
Minternal/tui/dashboard.go | 100+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
5 files changed, 718 insertions(+), 54 deletions(-)

diff --git a/cmd/sync.go b/cmd/sync.go @@ -5,6 +5,7 @@ import ( "os" "os/signal" "path/filepath" + "strings" "syscall" "time" @@ -159,38 +160,41 @@ func runTUI(cfg *config.Config, s *syncer.Syncer) error { syncCh := app.SyncEventChan() handler := func() { - // Send a "syncing" event before starting - syncCh <- tui.SyncEvent{ - File: cfg.Sync.Local, - Status: "syncing", - Time: time.Now(), - } + // Update header status to syncing + syncCh <- tui.SyncEvent{Status: "status:syncing"} result, err := s.Run() now := time.Now() if err != nil { syncCh <- tui.SyncEvent{ - File: cfg.Sync.Local, + File: "sync error", Status: "error", Time: now, } + syncCh <- tui.SyncEvent{Status: "status:watching"} return } - // Send individual file events - for _, f := range result.Files { + // Group files by top-level directory + groups := groupFilesByTopLevel(result.Files) + for _, g := range groups { + file := g.name + size := formatSize(g.bytes) + if g.count > 1 { + size = fmt.Sprintf("%d files %s", g.count, formatSize(g.bytes)) + } syncCh <- tui.SyncEvent{ - File: f.Name, - Size: formatSize(f.Bytes), + File: file, + Size: size, Duration: result.Duration, Status: "synced", Time: now, } } - // If no individual files reported, send a summary event - if len(result.Files) == 0 && result.FilesCount > 0 { + // Fallback: rsync ran but no individual files parsed + if len(groups) == 0 && result.FilesCount > 0 { syncCh <- tui.SyncEvent{ File: fmt.Sprintf("%d files", result.FilesCount), Size: formatSize(result.BytesTotal), @@ -199,6 +203,9 @@ func runTUI(cfg *config.Config, s *syncer.Syncer) error { Time: now, } } + + // Reset header status + syncCh <- tui.SyncEvent{Status: "status:watching"} } w, err := watcher.New( @@ -215,6 +222,13 @@ func runTUI(cfg *config.Config, s *syncer.Syncer) error { return fmt.Errorf("starting watcher: %w", err) } + resyncCh := app.ResyncChan() + go func() { + for range resyncCh { + handler() + } + }() + p := tea.NewProgram(app, tea.WithAltScreen()) if _, err := p.Run(); err != nil { w.Stop() @@ -334,3 +348,46 @@ func formatSize(bytes int64) string { return fmt.Sprintf("%dB", bytes) } } + +// groupedEvent represents a top-level directory or root file for the TUI. +type groupedEvent struct { + name string // "cmd/" or "main.go" + count int // number of files (1 for root files) + bytes int64 // total bytes +} + +// groupFilesByTopLevel collapses file entries into top-level directories +// and root files. "cmd/sync.go" + "cmd/init.go" become one entry "cmd/" with count=2. +func groupFilesByTopLevel(files []syncer.FileEntry) []groupedEvent { + dirMap := make(map[string]*groupedEvent) + var rootFiles []groupedEvent + var dirOrder []string + + for _, f := range files { + parts := strings.SplitN(f.Name, "/", 2) + if len(parts) == 1 { + // Root-level file + rootFiles = append(rootFiles, groupedEvent{ + name: f.Name, + count: 1, + bytes: f.Bytes, + }) + } else { + dir := parts[0] + "/" + if g, ok := dirMap[dir]; ok { + g.count++ + g.bytes += f.Bytes + } else { + dirMap[dir] = &groupedEvent{name: dir, count: 1, bytes: f.Bytes} + dirOrder = append(dirOrder, dir) + } + } + } + + var out []groupedEvent + for _, dir := range dirOrder { + out = append(out, *dirMap[dir]) + } + out = append(out, rootFiles...) + return out +} diff --git a/docs/plans/2026-03-01-tui-improvements-design.md b/docs/plans/2026-03-01-tui-improvements-design.md @@ -0,0 +1,70 @@ +# TUI Improvements Design + +## Problems + +1. **"Syncing" events pollute the event list.** The handler sends `{File: ".", Status: "syncing"}` before every rsync run. These pile up as permanent `⟳ . syncing...` rows and never clear. + +2. **Per-file events don't scale.** A sync transferring 1000 files would produce 1000 event rows, overwhelming the list and slowing TUI updates. + +3. **Event list doesn't fill the terminal.** The visible row count uses a hardcoded `height-10` offset. Tall terminals waste space; short terminals clip. + +4. **No scrolling or timestamps.** Events are a flat, non-navigable list with no time information. + +5. **`r` (full resync) is a dead key.** Shown in the help bar but has no handler. + +6. **Stats bar shows 0.** The `totalSynced` / `totalBytes` / `totalErrors` counters are never updated because nothing sends `SyncStatsMsg`. + +## Design + +### Syncing indicator: transient header status + +Remove "syncing" events from the event list. Add a new message type `SyncStatusMsg string` that updates only the header status line. The handler sends `SyncStatusMsg("syncing")` before rsync runs and `SyncStatusMsg("watching")` after. No syncing rows appear in the event list. + +### Top-level grouping of file events + +After rsync completes, the handler in `cmd/sync.go` groups `result.Files` by top-level path component: + +- Files in subdirectories are grouped by their first path segment. `cmd/sync.go` + `cmd/init.go` + `cmd/root.go` become one event: `✓ cmd/ 3 files 12.3KB`. +- Files at the root level get individual events: `✓ main.go 2.1KB`. + +Grouping happens in the handler after rsync returns, so it adds no overhead to the transfer. The TUI receives at most `N_top_level_dirs + N_root_files` events per sync. + +### Event list fills terminal, scrollable with timestamps + +**Layout**: compute available event rows as `height - 6`: +- Header: 3 lines (title, paths, status + blank) +- Stats + help: 3 lines + +Pad with empty lines when fewer events exist so the section always fills. + +**Timestamps**: each event row includes `HH:MM:SS` from `evt.Time`: +``` + 15:04:05 ✓ cmd/ 3 files 12.3KB 120ms + 15:04:05 ✓ main.go 2.1KB 120ms + 15:03:58 ✓ internal/ 5 files 45.2KB 200ms +``` + +**Scrolling**: add `offset int` to `DashboardModel`. `j`/`k` or `↑`/`↓` move the viewport. The event list is a window into `filteredEvents()[offset:offset+viewHeight]`. + +### `r` triggers full resync + +Add a `resyncCh chan struct{}` to `AppModel`, exposed via `ResyncChan()`. When the user presses `r`, the dashboard emits a `ResyncRequestMsg`. AppModel catches it and sends on the channel. The handler in `cmd/sync.go` listens on `resyncCh` in a goroutine and calls `s.Run()` when signalled, feeding results back through the existing event channel. + +### Stats bar accumulates + +The handler updates running totals (`totalSynced`, `totalBytes`, `totalErrors`) after each sync and sends a `SyncStatsMsg`. The dashboard renders these in the stats section. + +## Event row format + +``` + HH:MM:SS icon name(padded) detail size duration + 15:04:05 ✓ cmd/ 3 files 12.3KB 120ms + 15:04:05 ✓ main.go 2.1KB 120ms + 15:04:05 ✗ internal/ error ─ ─ +``` + +## Files to change + +- `internal/tui/dashboard.go` — timestamps, scrolling, fill terminal, remove syncing events +- `internal/tui/app.go` — new message types (`SyncStatusMsg`, `ResyncRequestMsg`), resync channel +- `cmd/sync.go` — top-level grouping, stats accumulation, resync listener, remove syncing event send diff --git a/docs/plans/2026-03-01-tui-improvements-plan.md b/docs/plans/2026-03-01-tui-improvements-plan.md @@ -0,0 +1,495 @@ +# TUI Improvements Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix the dashboard event list to show grouped top-level results with timestamps, fill the terminal, scroll, and wire up the `r` resync key. + +**Architecture:** Replace per-file "syncing"/"synced" events with a status message for the header and grouped directory-level events. Add scroll offset to the dashboard. Add a resync channel so the TUI can trigger a full sync. Accumulate stats in the handler. + +**Tech Stack:** Go, Bubbletea, Lipgloss + +--- + +### Task 1: Add new message types to app.go + +**Files:** +- Modify: `internal/tui/app.go` + +**Step 1: Add SyncStatusMsg and ResyncRequestMsg types and resync channel** + +In `internal/tui/app.go`, add after the `view` constants (line 16): + +```go +// SyncStatusMsg updates the header status without adding an event. +type SyncStatusMsg string + +// ResyncRequestMsg signals that the user pressed 'r' for a full resync. +type ResyncRequestMsg struct{} +``` + +Add `resyncCh` field to `AppModel` struct (after `logEntries`): + +```go +resyncCh chan struct{} +``` + +Initialize it in `NewApp`: + +```go +resyncCh: make(chan struct{}, 1), +``` + +Add accessor: + +```go +// ResyncChan returns a channel that receives when the user requests a full resync. +func (m *AppModel) ResyncChan() <-chan struct{} { + return m.resyncCh +} +``` + +**Step 2: Handle new messages in AppModel.Update** + +In the `Update` method's switch, add cases before the `SyncEventMsg` case: + +```go +case SyncStatusMsg: + m.dashboard.status = string(msg) + return m, nil + +case ResyncRequestMsg: + select { + case m.resyncCh <- struct{}{}: + default: + } + return m, nil +``` + +**Step 3: Build and verify compilation** + +Run: `go build ./...` +Expected: success + +**Step 4: Commit** + +```bash +git add internal/tui/app.go +git commit -m "feat(tui): add SyncStatusMsg, ResyncRequestMsg, resync channel" +``` + +--- + +### Task 2: Update dashboard — timestamps, scrolling, fill terminal + +**Files:** +- Modify: `internal/tui/dashboard.go` + +**Step 1: Add scroll offset field** + +Add `offset int` to `DashboardModel` struct (after `filtering`). + +**Step 2: Add j/k/up/down scroll keys and r resync key to updateNormal** + +Replace `updateNormal`: + +```go +func (m DashboardModel) updateNormal(msg tea.KeyMsg) (DashboardModel, tea.Cmd) { + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "p": + if m.status == "paused" { + m.status = "watching" + } else { + m.status = "paused" + } + 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++ + } + case "k", "up": + if m.offset > 0 { + m.offset-- + } + case "/": + m.filtering = true + m.filter = "" + m.offset = 0 + } + return m, nil +} +``` + +**Step 3: Add eventViewHeight helper** + +```go +// eventViewHeight returns the number of event rows that fit in the terminal. +// Layout: header (3 lines) + "Recent" header (1) + stats section (3) + help (1) = 8 fixed. +func (m DashboardModel) eventViewHeight() int { + return max(1, m.height-8) +} +``` + +**Step 4: Rewrite View to fill terminal with timestamps** + +Replace the `View` method: + +```go +func (m DashboardModel) View() string { + var b strings.Builder + + // --- Header (3 lines) --- + header := titleStyle.Render(" esync ") + dimStyle.Render(strings.Repeat("─", max(0, m.width-8))) + b.WriteString(header + "\n") + b.WriteString(fmt.Sprintf(" %s → %s\n", m.local, m.remote)) + + statusIcon, statusText := m.statusDisplay() + agoText := "" + if !m.lastSync.IsZero() { + ago := time.Since(m.lastSync).Truncate(time.Second) + agoText = fmt.Sprintf(" (synced %s ago)", ago) + } + b.WriteString(fmt.Sprintf(" %s %s%s\n", statusIcon, statusText, dimStyle.Render(agoText))) + + // --- Recent events --- + b.WriteString(" " + titleStyle.Render("Recent") + " " + dimStyle.Render(strings.Repeat("─", max(0, m.width-11))) + "\n") + + 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") + } + // Pad empty rows + for i := end - start; i < vh; i++ { + b.WriteString("\n") + } + + // --- Stats (2 lines) --- + b.WriteString(" " + titleStyle.Render("Stats") + " " + dimStyle.Render(strings.Repeat("─", max(0, m.width-10))) + "\n") + stats := fmt.Sprintf(" %d synced │ %s total │ %d errors", + m.totalSynced, m.totalBytes, m.totalErrors) + b.WriteString(stats + "\n") + + // --- Help (1 line) --- + 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" + if m.filter != "" { + help += fmt.Sprintf(" [filter: %s]", m.filter) + } + b.WriteString(helpStyle.Render(help)) + } + b.WriteString("\n") + + return b.String() +} +``` + +**Step 5: Update renderEvent to include timestamp** + +Replace `renderEvent`: + +```go +func (m DashboardModel) renderEvent(evt SyncEvent) string { + ts := dimStyle.Render(evt.Time.Format("15:04:05")) + switch evt.Status { + case "synced": + name := padRight(evt.File, 30) + detail := "" + if evt.Size != "" { + detail = dimStyle.Render(fmt.Sprintf("%8s %s", evt.Size, evt.Duration.Truncate(100*time.Millisecond))) + } + return ts + " " + statusSynced.Render("✓") + " " + name + detail + case "error": + name := padRight(evt.File, 30) + return ts + " " + statusError.Render("✗") + " " + name + statusError.Render("error") + default: + return ts + " " + evt.File + } +} +``` + +**Step 6: Remove "syncing" case from renderEvent** + +The "syncing" case is no longer needed — it was removed in step 5 above. + +**Step 7: Build and verify** + +Run: `go build ./...` +Expected: success + +**Step 8: Commit** + +```bash +git add internal/tui/dashboard.go +git commit -m "feat(tui): timestamps, scrolling, fill terminal, resync key" +``` + +--- + +### Task 3: Top-level grouping and stats accumulation in handler + +**Files:** +- Modify: `cmd/sync.go` + +**Step 1: Add groupFiles helper** + +Add after `formatSize` at the bottom of `cmd/sync.go`: + +```go +// groupedEvent represents a top-level directory or root file for the TUI. +type groupedEvent struct { + name string // "cmd/" or "main.go" + count int // number of files (1 for root files) + bytes int64 // total bytes +} + +// groupFilesByTopLevel collapses file entries into top-level directories +// and root files. "cmd/sync.go" + "cmd/init.go" → one entry "cmd/" with count=2. +func groupFilesByTopLevel(files []syncer.FileEntry) []groupedEvent { + dirMap := make(map[string]*groupedEvent) + var rootFiles []groupedEvent + var dirOrder []string + + for _, f := range files { + parts := strings.SplitN(f.Name, "/", 2) + if len(parts) == 1 { + // Root-level file + rootFiles = append(rootFiles, groupedEvent{ + name: f.Name, + count: 1, + bytes: f.Bytes, + }) + } else { + dir := parts[0] + "/" + if g, ok := dirMap[dir]; ok { + g.count++ + g.bytes += f.Bytes + } else { + dirMap[dir] = &groupedEvent{name: dir, count: 1, bytes: f.Bytes} + dirOrder = append(dirOrder, dir) + } + } + } + + var out []groupedEvent + for _, dir := range dirOrder { + out = append(out, *dirMap[dir]) + } + out = append(out, rootFiles...) + return out +} +``` + +**Step 2: Rewrite TUI handler to use grouping, stats, and status messages** + +Replace the entire `handler` closure inside `runTUI`: + +```go + var totalSynced int + var totalBytes int64 + var totalErrors int + + handler := func() { + // Update header status + syncCh <- tui.SyncEvent{Status: "status:syncing"} + + result, err := s.Run() + now := time.Now() + + if err != nil { + totalErrors++ + syncCh <- tui.SyncEvent{ + File: "sync error", + Status: "error", + Time: now, + } + // Reset header + syncCh <- tui.SyncEvent{Status: "status:watching"} + return + } + + // Group files by top-level directory + groups := groupFilesByTopLevel(result.Files) + for _, g := range groups { + file := g.name + size := formatSize(g.bytes) + if g.count > 1 { + file = g.name + size = fmt.Sprintf("%d files %s", g.count, formatSize(g.bytes)) + } + syncCh <- tui.SyncEvent{ + File: file, + Size: size, + Duration: result.Duration, + Status: "synced", + Time: now, + } + } + + // Fallback: rsync ran but no individual files parsed + if len(groups) == 0 && result.FilesCount > 0 { + syncCh <- tui.SyncEvent{ + File: fmt.Sprintf("%d files", result.FilesCount), + Size: formatSize(result.BytesTotal), + Duration: result.Duration, + Status: "synced", + Time: now, + } + } + + // Accumulate stats + totalSynced += result.FilesCount + totalBytes += result.BytesTotal + + // Reset header + syncCh <- tui.SyncEvent{Status: "status:watching"} + } +``` + +**Step 3: Handle status messages in dashboard Update** + +In `internal/tui/dashboard.go`, update the `SyncEventMsg` case in `Update`: + +```go +case SyncEventMsg: + evt := SyncEvent(msg) + + // Status-only messages update the header, not the event list + if strings.HasPrefix(evt.Status, "status:") { + m.status = strings.TrimPrefix(evt.Status, "status:") + return m, nil + } + + // Prepend event; cap at 500. + m.events = append([]SyncEvent{evt}, m.events...) + if len(m.events) > 500 { + m.events = m.events[:500] + } + if evt.Status == "synced" { + m.lastSync = evt.Time + } + return m, nil +``` + +Add `"strings"` to the imports in `dashboard.go` if not already present (it is). + +**Step 4: Send stats after each sync** + +Still in the TUI handler in `cmd/sync.go`, after the status reset, send stats. But we're using the same `syncCh` channel which sends `SyncEvent`. We need a different approach. + +Simpler: update the dashboard's stats directly from the event stream. In `dashboard.go`, update the `SyncEventMsg` handler to accumulate stats: + +```go +if evt.Status == "synced" { + m.lastSync = evt.Time + m.totalSynced++ + // Parse size back (or just count events) +} +``` + +Actually, the simplest approach: count synced events and track the `lastSync` time. Remove the `SyncStatsMsg` type and the `totalBytes` / `totalErrors` fields. Replace the stats bar with just event count + last sync time. The exact byte total isn't meaningful in grouped view anyway. + +Replace stats rendering in `View`: + +```go +// --- Stats (2 lines) --- +b.WriteString(" " + titleStyle.Render("Stats") + " " + dimStyle.Render(strings.Repeat("─", max(0, m.width-10))) + "\n") +stats := fmt.Sprintf(" %d events │ %d errors", m.totalSynced, m.totalErrors) +b.WriteString(stats + "\n") +``` + +In the `SyncEventMsg` handler, increment counters: + +```go +if evt.Status == "synced" { + m.lastSync = evt.Time + m.totalSynced++ +} else if evt.Status == "error" { + m.totalErrors++ +} +``` + +Remove `totalBytes string` from `DashboardModel` and `SyncStatsMsg` type from `dashboard.go`. Remove the `SyncStatsMsg` case from `Update`. + +**Step 5: Wire up resync channel in cmd/sync.go** + +In `runTUI`, after starting the watcher and before creating the tea.Program, add a goroutine: + +```go + resyncCh := app.ResyncChan() + go func() { + for range resyncCh { + handler() + } + }() +``` + +**Step 6: Build and verify** + +Run: `go build ./...` +Expected: success + +**Step 7: Run tests** + +Run: `go test ./...` +Expected: all pass + +**Step 8: Commit** + +```bash +git add cmd/sync.go internal/tui/dashboard.go +git commit -m "feat(tui): top-level grouping, stats accumulation, resync wiring" +``` + +--- + +### Task 4: End-to-end verification + +**Step 1: Build binary** + +```bash +GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o esync-darwin-arm64 . +``` + +**Step 2: Test with local docs sync** + +```bash +rm -rf /tmp/esync-docs && mkdir -p /tmp/esync-docs +./esync-darwin-arm64 sync --daemon -v +# In another terminal: touch docs/plans/2026-03-01-go-rewrite-design.md +# Verify: "Synced 2 files" appears +``` + +**Step 3: Test TUI** + +```bash +./esync-darwin-arm64 sync +# Verify: +# - Header shows "● Watching", switches to "⟳ Syncing" during rsync +# - Events show timestamps: "15:04:05 ✓ plans/ ..." +# - j/k scrolls the event list +# - r triggers a full resync +# - Event list fills terminal height +# - No "⟳ . syncing..." rows in the event list +``` + +**Step 4: Run full test suite** + +Run: `go test ./...` +Expected: all pass + +**Step 5: Commit design doc** + +```bash +git add docs/plans/ +git commit -m "docs: add TUI improvements design and plan" +``` diff --git a/internal/tui/app.go b/internal/tui/app.go @@ -15,6 +15,12 @@ const ( viewLogs ) +// SyncStatusMsg updates the header status without adding an event. +type SyncStatusMsg string + +// ResyncRequestMsg signals that the user pressed 'r' for a full resync. +type ResyncRequestMsg struct{} + // --------------------------------------------------------------------------- // AppModel — root Bubbletea model // --------------------------------------------------------------------------- @@ -27,6 +33,7 @@ type AppModel struct { current view syncEvents chan SyncEvent logEntries chan LogEntry + resyncCh chan struct{} } // NewApp creates a new AppModel wired to the given local and remote paths. @@ -37,6 +44,7 @@ func NewApp(local, remote string) *AppModel { current: viewDashboard, syncEvents: make(chan SyncEvent, 64), logEntries: make(chan LogEntry, 64), + resyncCh: make(chan struct{}, 1), } } @@ -52,6 +60,11 @@ func (m *AppModel) LogEntryChan() chan<- LogEntry { return m.logEntries } +// ResyncChan returns a channel that receives when the user requests a full resync. +func (m *AppModel) ResyncChan() <-chan struct{} { + return m.resyncCh +} + // --------------------------------------------------------------------------- // tea.Model interface // --------------------------------------------------------------------------- @@ -94,6 +107,17 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + case SyncStatusMsg: + m.dashboard.status = string(msg) + return m, nil + + case ResyncRequestMsg: + select { + case m.resyncCh <- struct{}{}: + default: + } + return m, nil + case SyncEventMsg: // Dispatch to dashboard and re-listen. var cmd tea.Cmd diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go @@ -18,13 +18,6 @@ type tickMsg time.Time // SyncEventMsg carries a single sync event into the TUI. type SyncEventMsg SyncEvent -// SyncStatsMsg carries aggregate sync statistics. -type SyncStatsMsg struct { - TotalSynced int - TotalBytes string - TotalErrors int -} - // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -45,11 +38,11 @@ type DashboardModel struct { lastSync time.Time events []SyncEvent totalSynced int - totalBytes string totalErrors int width, height int filter string filtering bool + offset int } // --------------------------------------------------------------------------- @@ -60,10 +53,9 @@ type DashboardModel struct { // remote paths. func NewDashboard(local, remote string) DashboardModel { return DashboardModel{ - local: local, - remote: remote, - status: "watching", - totalBytes: "0B", + local: local, + remote: remote, + status: "watching", } } @@ -96,22 +88,26 @@ func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) { case SyncEventMsg: evt := SyncEvent(msg) - // Prepend; cap at 100. + + // Status-only messages update the header, not the event list + if strings.HasPrefix(evt.Status, "status:") { + m.status = strings.TrimPrefix(evt.Status, "status:") + return m, nil + } + + // Prepend event; cap at 500. m.events = append([]SyncEvent{evt}, m.events...) - if len(m.events) > 100 { - m.events = m.events[:100] + if len(m.events) > 500 { + m.events = m.events[:500] } if evt.Status == "synced" { m.lastSync = evt.Time + m.totalSynced++ + } else if evt.Status == "error" { + m.totalErrors++ } return m, nil - case SyncStatsMsg: - m.totalSynced = msg.TotalSynced - m.totalBytes = msg.TotalBytes - m.totalErrors = msg.TotalErrors - return m, nil - case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height @@ -132,9 +128,22 @@ func (m DashboardModel) updateNormal(msg tea.KeyMsg) (DashboardModel, tea.Cmd) { } else { m.status = "paused" } + 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++ + } + case "k", "up": + if m.offset > 0 { + m.offset-- + } case "/": m.filtering = true m.filter = "" + m.offset = 0 } return m, nil } @@ -159,16 +168,21 @@ func (m DashboardModel) updateFiltering(msg tea.KeyMsg) (DashboardModel, tea.Cmd return m, nil } +// eventViewHeight returns the number of event rows that fit in the terminal. +// Layout: header (3 lines) + "Recent" header (1) + stats section (3) + help (1) = 8 fixed. +func (m DashboardModel) eventViewHeight() int { + return max(1, m.height-8) +} + // View renders the dashboard. func (m DashboardModel) View() string { var b strings.Builder - // --- Header --- + // --- Header (3 lines) --- header := titleStyle.Render(" esync ") + dimStyle.Render(strings.Repeat("─", max(0, m.width-8))) b.WriteString(header + "\n") b.WriteString(fmt.Sprintf(" %s → %s\n", m.local, m.remote)) - // Status line statusIcon, statusText := m.statusDisplay() agoText := "" if !m.lastSync.IsZero() { @@ -176,31 +190,33 @@ func (m DashboardModel) View() string { agoText = fmt.Sprintf(" (synced %s ago)", ago) } b.WriteString(fmt.Sprintf(" %s %s%s\n", statusIcon, statusText, dimStyle.Render(agoText))) - b.WriteString("\n") // --- Recent events --- b.WriteString(" " + titleStyle.Render("Recent") + " " + dimStyle.Render(strings.Repeat("─", max(0, m.width-11))) + "\n") filtered := m.filteredEvents() - visible := min(len(filtered), max(0, m.height-10)) - for i := 0; i < visible; i++ { - evt := filtered[i] - b.WriteString(" " + m.renderEvent(evt) + "\n") + 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") + } + // Pad empty rows + for i := end - start; i < vh; i++ { + b.WriteString("\n") } - b.WriteString("\n") - // --- Stats --- + // --- Stats (2 lines) --- b.WriteString(" " + titleStyle.Render("Stats") + " " + dimStyle.Render(strings.Repeat("─", max(0, m.width-10))) + "\n") - stats := fmt.Sprintf(" %d synced │ %s total │ %d errors", - m.totalSynced, m.totalBytes, m.totalErrors) + stats := fmt.Sprintf(" %d events │ %d errors", m.totalSynced, m.totalErrors) b.WriteString(stats + "\n") - b.WriteString("\n") - // --- Help / filter --- + // --- Help (1 line) --- if m.filtering { b.WriteString(helpStyle.Render(fmt.Sprintf(" filter: %s█ (enter apply esc clear)", m.filter))) } else { - help := " q quit p pause r full resync l logs d dry-run / filter" + help := " q quit p pause r resync ↑↓ scroll l logs / filter" if m.filter != "" { help += fmt.Sprintf(" [filter: %s]", m.filter) } @@ -233,18 +249,20 @@ func (m DashboardModel) statusDisplay() (string, string) { // renderEvent formats a single sync event line. func (m DashboardModel) renderEvent(evt SyncEvent) string { + ts := dimStyle.Render(evt.Time.Format("15:04:05")) switch evt.Status { case "synced": name := padRight(evt.File, 30) - return statusSynced.Render("✓") + " " + name + dimStyle.Render(fmt.Sprintf("%8s %5s", evt.Size, evt.Duration.Truncate(100*time.Millisecond))) - case "syncing": - name := padRight(evt.File, 30) - return statusSyncing.Render("⟳") + " " + name + statusSyncing.Render("syncing...") + detail := "" + if evt.Size != "" { + detail = dimStyle.Render(fmt.Sprintf("%8s %s", evt.Size, evt.Duration.Truncate(100*time.Millisecond))) + } + return ts + " " + statusSynced.Render("✓") + " " + name + detail case "error": name := padRight(evt.File, 30) - return statusError.Render("✗") + " " + name + statusError.Render("error") + return ts + " " + statusError.Render("✗") + " " + name + statusError.Render("error") default: - return evt.File + return ts + " " + evt.File } }