Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6786b51
feat: add Files field to SyncEvent for directory group children
eloualiche Mar 4, 2026
93ccfaf
feat: populate SyncEvent.Files with individual paths for directory gr…
eloualiche Mar 4, 2026
12134c6
feat: add cursor and expanded state to DashboardModel
eloualiche Mar 4, 2026
64f6412
feat: cursor navigation with expand/collapse for dashboard events
eloualiche Mar 4, 2026
379844e
feat: limit stored file list to 10 per event and add FileCount
eloualiche Mar 4, 2026
c195e15
test: add tests for groupFilesByTopLevel with files field
eloualiche Mar 4, 2026
549e484
docs: add design and plan for cursor navigation and inline expand
eloualiche Mar 4, 2026
027bd50
docs: add design for include filter configuration
eloualiche Mar 8, 2026
ba0ab00
docs: add implementation plan for include filter
eloualiche Mar 8, 2026
a7b4d49
feat: add Include field to Settings config
eloualiche Mar 8, 2026
07f5a2a
feat: add shouldInclude path-prefix filtering to watcher
eloualiche Mar 8, 2026
fc6d672
feat: pass include patterns to watcher from config
eloualiche Mar 8, 2026
4ffb53b
feat: emit rsync include/exclude filter rules from config
eloualiche Mar 8, 2026
f057007
test: assert named excludes precede catch-all in include mode
eloualiche Mar 8, 2026
a7041e4
test: verify DefaultTOML includes the include field
eloualiche Mar 8, 2026
4ffe890
docs: document include filter in README
eloualiche Mar 8, 2026
25994f6
fix: handle individual files in include patterns, apply include filte…
eloualiche Mar 8, 2026
4b87514
fix: right-align file count, size, and duration columns in dashboard
eloualiche Mar 8, 2026
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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,10 @@ watcher_debounce = 500
# Run a full sync when esync starts (default: false)
initial_sync = false

# Path prefixes to sync, relative to local. Empty means everything.
# Keep include simple and explicit; use ignore for fine-grained filtering.
include = []

# Patterns to ignore β€” applied to both the watcher and rsync --exclude flags.
# Supports glob patterns. Matched against file/directory base names.
ignore = [
Expand Down Expand Up @@ -283,6 +287,25 @@ extra_args = [
]
```

### Include Filters (Monorepo Support)

In a large repo, you may only want to sync specific subtrees. Use `include` to name the directories you care about, then use `ignore` for fine-grained filtering within them:

```toml
[sync]
local = "/home/user/monorepo"
remote = "server:/opt/monorepo"

[settings]
include = ["src", "docs/api"]
ignore = [".git", "node_modules", ".DS_Store"]
```

- `include` takes path prefixes relative to `local` (not globs)
- Empty `include` (the default) means sync everything β€” fully backwards compatible
- When set, only files under the listed prefixes are watched and synced
- `ignore` then further refines within the included paths

### Separate Watcher and Rsync Ignore Patterns

The top-level `settings.ignore` patterns are used by both the file watcher and rsync. If you need rsync-specific excludes (patterns the watcher should still see), use `settings.rsync.ignore`:
Expand Down
31 changes: 31 additions & 0 deletions cmd/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ type fileEntry struct {
func printPreview(cfg *config.Config) error {
localDir := cfg.Sync.Local
patterns := cfg.AllIgnorePatterns()
includes := cfg.Settings.Include

var included []fileEntry
var excluded []fileEntry
Expand Down Expand Up @@ -114,6 +115,14 @@ func printPreview(cfg *config.Config) error {
}
}

// Check against include patterns (if any)
if len(includes) > 0 && !matchesInclude(rel, includes) {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}

if !info.IsDir() {
included = append(included, fileEntry{path: rel})
includedSize += info.Size()
Expand Down Expand Up @@ -172,6 +181,28 @@ func printPreview(cfg *config.Config) error {
// Pattern matching
// ---------------------------------------------------------------------------

// matchesInclude checks whether a relative path falls under any include prefix.
// A path is included if it equals a prefix, is inside a prefix, or is an
// ancestor directory needed to reach a prefix.
func matchesInclude(rel string, includes []string) bool {
for _, inc := range includes {
inc = filepath.Clean(inc)
// Exact match (file or dir)
if rel == inc {
return true
}
// Path is inside the included prefix
if strings.HasPrefix(rel, inc+string(filepath.Separator)) {
return true
}
// Path is an ancestor of the included prefix
if strings.HasPrefix(inc, rel+string(filepath.Separator)) {
return true
}
}
return false
}

// matchesIgnorePattern checks whether a file (given its relative path and
// file info) matches a single ignore pattern. It handles bracket/quote
// stripping, ** prefixes, and directory-specific patterns.
Expand Down
41 changes: 29 additions & 12 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,15 +231,14 @@ func runTUI(cfg *config.Config, s *syncer.Syncer) error {
bytes = result.BytesTotal * int64(g.count) / int64(totalGroupFiles)
}
size := formatSize(bytes)
if g.count > 1 {
size = fmt.Sprintf("%d files %s", g.count, formatSize(bytes))
}
syncCh <- tui.SyncEvent{
File: file,
Size: size,
Duration: result.Duration,
Status: "synced",
Time: now,
File: file,
Size: size,
Duration: result.Duration,
Status: "synced",
Time: now,
Files: truncateFiles(g.files, 10),
FileCount: g.count,
}
}

Expand All @@ -262,6 +261,7 @@ func runTUI(cfg *config.Config, s *syncer.Syncer) error {
cfg.Sync.Local,
cfg.Settings.WatcherDebounce,
cfg.AllIgnorePatterns(),
cfg.Settings.Include,
handler,
)
if err != nil {
Expand Down Expand Up @@ -358,6 +358,7 @@ func runDaemon(cfg *config.Config, s *syncer.Syncer) error {
cfg.Sync.Local,
cfg.Settings.WatcherDebounce,
cfg.AllIgnorePatterns(),
cfg.Settings.Include,
handler,
)
if err != nil {
Expand Down Expand Up @@ -401,9 +402,10 @@ func formatSize(bytes int64) string {

// 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
name string // "cmd/" or "main.go"
count int // number of files (1 for root files)
bytes int64 // total bytes
files []string // individual file paths within the group
}

// groupFilesByTopLevel collapses file entries into top-level directories
Expand All @@ -430,8 +432,14 @@ func groupFilesByTopLevel(files []syncer.FileEntry) []groupedEvent {
if g, ok := dirMap[dir]; ok {
g.count++
g.bytes += f.Bytes
g.files = append(g.files, f.Name)
} else {
dirMap[dir] = &groupedEvent{name: dir, count: 1, bytes: f.Bytes}
dirMap[dir] = &groupedEvent{
name: dir,
count: 1,
bytes: f.Bytes,
files: []string{f.Name},
}
dirFirstFile[dir] = f.Name
dirOrder = append(dirOrder, dir)
}
Expand All @@ -443,9 +451,18 @@ func groupFilesByTopLevel(files []syncer.FileEntry) []groupedEvent {
g := *dirMap[dir]
if g.count == 1 {
g.name = dirFirstFile[dir]
g.files = nil
}
out = append(out, g)
}
out = append(out, rootFiles...)
return out
}

// truncateFiles returns at most n elements from files.
func truncateFiles(files []string, n int) []string {
if len(files) <= n {
return files
}
return files[:n]
}
65 changes: 65 additions & 0 deletions cmd/sync_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package cmd

import (
"testing"

"github.com/louloulibs/esync/internal/syncer"
)

func TestGroupFilesByTopLevel_MultiFile(t *testing.T) {
files := []syncer.FileEntry{
{Name: "cmd/sync.go", Bytes: 100},
{Name: "cmd/root.go", Bytes: 200},
{Name: "main.go", Bytes: 50},
}

groups := groupFilesByTopLevel(files)

if len(groups) != 2 {
t.Fatalf("got %d groups, want 2", len(groups))
}

// First group: cmd/ with 2 files
g := groups[0]
if g.name != "cmd/" {
t.Errorf("group[0].name = %q, want %q", g.name, "cmd/")
}
if g.count != 2 {
t.Errorf("group[0].count = %d, want 2", g.count)
}
if len(g.files) != 2 {
t.Fatalf("group[0].files has %d entries, want 2", len(g.files))
}
if g.files[0] != "cmd/sync.go" || g.files[1] != "cmd/root.go" {
t.Errorf("group[0].files = %v, want [cmd/sync.go cmd/root.go]", g.files)
}

// Second group: root file
g = groups[1]
if g.name != "main.go" {
t.Errorf("group[1].name = %q, want %q", g.name, "main.go")
}
if g.files != nil {
t.Errorf("group[1].files should be nil for root file, got %v", g.files)
}
}

func TestGroupFilesByTopLevel_SingleFileDir(t *testing.T) {
files := []syncer.FileEntry{
{Name: "internal/config/config.go", Bytes: 300},
}

groups := groupFilesByTopLevel(files)

if len(groups) != 1 {
t.Fatalf("got %d groups, want 1", len(groups))
}

g := groups[0]
if g.name != "internal/config/config.go" {
t.Errorf("name = %q, want full path", g.name)
}
if g.files != nil {
t.Errorf("files should be nil for single-file dir, got %v", g.files)
}
}
62 changes: 62 additions & 0 deletions docs/plans/2026-03-03-cursor-expand-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Cursor Navigation & Inline Expand for TUI Dashboard

## Problem

The dashboard shows grouped sync events (e.g. "cmd/ 2 files") but provides no
way to inspect which files are inside a group. Users need to see individual
file paths without leaving the dashboard.

## Design

### 1. Cursor navigation

Add a `cursor int` field to `DashboardModel`. Up/down arrows move the cursor
through the filtered event list. The focused row gets a subtle highlight β€” a
`>` marker and brighter text. The viewport auto-scrolls to keep the cursor
visible.

### 2. Individual files in SyncEvent

Add `Files []string` to the `SyncEvent` struct. When `groupFilesByTopLevel()`
in `cmd/sync.go` produces a group with `count > 1`, populate `Files` with the
individual relative paths from that group.

### 3. Inline expand/collapse

Add `expanded map[int]bool` to `DashboardModel` (keyed by event index in the
unfiltered `events` slice). Press Enter on a focused event to toggle. When
expanded, child files render below the parent, indented:

```
14:32:05 βœ“ cmd/ 2 files 1.2KB 0.3s
β”” cmd/sync.go
β”” cmd/root.go
14:32:01 βœ“ main.go 0.5KB 0.1s
```

Single-file events (empty `Files`) ignore the expand action.

### 4. Column alignment

The current layout uses a fixed 30-char name column. With the terminal width
available, use more space:

- Timestamp: fixed 8 chars
- Status icon: 1 char + spacing
- Name column: dynamic, scales with terminal width (min 30, up to width - 40)
- Size + duration: right-aligned in remaining space
- Expanded child lines: indented under the name column, same alignment

Child file names use the full name column width minus 2 chars for the `β”” `
prefix.

### 5. Key bindings

| Key | Action |
|------------|-------------------------------------------|
| j / ↓ | Move cursor down |
| k / ↑ | Move cursor up |
| Enter / β†’ | Toggle expand on focused event |
| Left / Esc | Collapse focused event (if expanded) |

The help line updates to show the new bindings.
Loading