Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 70 additions & 13 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"

Expand Down Expand Up @@ -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),
Expand All @@ -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(
Expand All @@ -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()
Expand Down Expand Up @@ -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
}
70 changes: 70 additions & 0 deletions docs/plans/2026-03-01-tui-improvements-design.md
Original file line number Diff line number Diff line change
@@ -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
Loading