commit 4c56b80bafe0999f2f14de8a78667ff0d3cdd825
parent 2639d4d30fda9cf3270e836a20d6b45569aea1a3
Author: Erik Loualiche <eloualiche@users.noreply.github.com>
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 <noreply@anthropic.com>
Diffstat:
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
}
}