Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ test-sync
tests
/esync.toml
sync.log
.worktrees/
22 changes: 22 additions & 0 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package tui

import (
"os"
"os/exec"

tea "github.com/charmbracelet/bubbletea"
)

Expand All @@ -21,6 +24,11 @@ type SyncStatusMsg string
// ResyncRequestMsg signals that the user pressed 'r' for a full resync.
type ResyncRequestMsg struct{}

// OpenFileMsg signals that the user wants to open a file in their editor.
type OpenFileMsg struct{ Path string }

type editorFinishedMsg struct{ err error }

// ---------------------------------------------------------------------------
// AppModel — root Bubbletea model
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -118,6 +126,20 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil

case OpenFileMsg:
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "less"
}
c := exec.Command(editor, msg.Path)
return m, tea.ExecProcess(c, func(err error) tea.Msg {
return editorFinishedMsg{err}
})

case editorFinishedMsg:
// Editor exited; nothing to do on success.
return m, nil

case SyncEventMsg:
// Dispatch to dashboard and re-listen.
var cmd tea.Cmd
Expand Down
140 changes: 123 additions & 17 deletions internal/tui/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tui

import (
"fmt"
"path/filepath"
"strings"
"time"

Expand Down Expand Up @@ -46,6 +47,7 @@ type DashboardModel struct {
filtering bool
offset int
cursor int // index into filtered events
childCursor int // -1 = on parent row, >=0 = index into expanded Files
expanded map[int]bool // keyed by index in unfiltered events slice
}

Expand All @@ -57,10 +59,11 @@ type DashboardModel struct {
// remote paths.
func NewDashboard(local, remote string) DashboardModel {
return DashboardModel{
local: local,
remote: remote,
status: "watching",
expanded: make(map[int]bool),
local: local,
remote: remote,
status: "watching",
childCursor: -1,
expanded: make(map[int]bool),
}
}

Expand Down Expand Up @@ -106,6 +109,7 @@ func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) {
newExpanded[idx+1] = v
}
m.expanded = newExpanded
m.childCursor = -1

// Prepend event; cap at 500.
m.events = append([]SyncEvent{evt}, m.events...)
Expand Down Expand Up @@ -138,7 +142,6 @@ 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":
Expand All @@ -152,14 +155,10 @@ func (m DashboardModel) updateNormal(msg tea.KeyMsg) (DashboardModel, tea.Cmd) {
case "r":
return m, func() tea.Msg { return ResyncRequestMsg{} }
case "j", "down":
if m.cursor < maxCursor {
m.cursor++
}
m.moveDown(filtered)
m.ensureCursorVisible()
case "k", "up":
if m.cursor > 0 {
m.cursor--
}
m.moveUp(filtered)
m.ensureCursorVisible()
case "enter", "right":
if m.cursor < len(filtered) {
Expand All @@ -168,6 +167,7 @@ func (m DashboardModel) updateNormal(msg tea.KeyMsg) (DashboardModel, tea.Cmd) {
idx := m.unfilteredIndex(m.cursor)
if idx >= 0 {
m.expanded[idx] = !m.expanded[idx]
m.childCursor = -1
}
}
}
Expand All @@ -176,13 +176,41 @@ func (m DashboardModel) updateNormal(msg tea.KeyMsg) (DashboardModel, tea.Cmd) {
idx := m.unfilteredIndex(m.cursor)
if idx >= 0 {
delete(m.expanded, idx)
m.childCursor = -1
}
}
case "v":
if m.cursor >= len(filtered) {
break
}
evt := filtered[m.cursor]
idx := m.unfilteredIndex(m.cursor)

// On a child file — open it
if m.childCursor >= 0 && m.childCursor < len(evt.Files) {
path := filepath.Join(m.local, evt.Files[m.childCursor])
return m, func() tea.Msg { return OpenFileMsg{Path: path} }
}

// On a parent with children — expand (same as enter)
if len(evt.Files) > 0 {
if idx >= 0 && !m.expanded[idx] {
m.expanded[idx] = true
return m, nil
}
// Already expanded but cursor on parent — do nothing
return m, nil
}

// Single-file event — open it
path := filepath.Join(m.local, evt.File)
return m, func() tea.Msg { return OpenFileMsg{Path: path} }
case "/":
m.filtering = true
m.filter = ""
m.cursor = 0
m.offset = 0
m.childCursor = -1
}
return m, nil
}
Expand All @@ -207,6 +235,70 @@ func (m DashboardModel) updateFiltering(msg tea.KeyMsg) (DashboardModel, tea.Cmd
return m, nil
}

// moveDown advances cursor one visual row, entering expanded children.
func (m *DashboardModel) moveDown(filtered []SyncEvent) {
if m.cursor >= len(filtered) {
return
}
idx := m.unfilteredIndex(m.cursor)
evt := filtered[m.cursor]

// Currently on parent of expanded event — enter children
if m.childCursor == -1 && idx >= 0 && m.expanded[idx] && len(evt.Files) > 0 {
m.childCursor = 0
return
}

// Currently on a child — advance within children
if m.childCursor >= 0 {
if m.childCursor < len(evt.Files)-1 {
m.childCursor++
return
}
// Past last child — move to next event
if m.cursor < len(filtered)-1 {
m.cursor++
m.childCursor = -1
}
return
}

// Normal: move to next event
if m.cursor < len(filtered)-1 {
m.cursor++
m.childCursor = -1
}
}

// moveUp moves cursor one visual row, entering expanded children from bottom.
func (m *DashboardModel) moveUp(filtered []SyncEvent) {
// Currently on a child — move up within children
if m.childCursor > 0 {
m.childCursor--
return
}

// On first child — move back to parent
if m.childCursor == 0 {
m.childCursor = -1
return
}

// On a parent row — move to previous event
if m.cursor <= 0 {
return
}
m.cursor--
m.childCursor = -1

// If previous event is expanded, land on its last child
prevIdx := m.unfilteredIndex(m.cursor)
prevEvt := filtered[m.cursor]
if prevIdx >= 0 && m.expanded[prevIdx] && len(prevEvt.Files) > 0 {
m.childCursor = len(prevEvt.Files) - 1
}
}

// 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 {
Expand Down Expand Up @@ -247,7 +339,11 @@ func (m DashboardModel) View() string {
// Render expanded children
idx := m.unfilteredIndex(i)
if idx >= 0 && m.expanded[idx] && len(filtered[i].Files) > 0 {
children := m.renderChildren(filtered[i].Files, filtered[i].FileCount, nw)
focusedChild := -1
if i == m.cursor {
focusedChild = m.childCursor
}
children := m.renderChildren(filtered[i].Files, filtered[i].FileCount, nw, focusedChild)
for _, child := range children {
if linesRendered >= vh {
break
Expand All @@ -271,7 +367,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 ↑↓ navigate enter expand l logs / filter"
help := " q quit p pause r resync ↑↓ navigate enter expand v view l logs / filter"
if m.filter != "" {
help += fmt.Sprintf(" [filter: %s]", m.filter)
}
Expand Down Expand Up @@ -409,7 +505,12 @@ func (m *DashboardModel) ensureCursorVisible() {
lines++ // the event row itself
idx := m.unfilteredIndex(i)
if idx >= 0 && m.expanded[idx] {
lines += expandedLineCount(filtered[i])
if i == m.cursor && m.childCursor >= 0 {
// Only count up to the focused child
lines += m.childCursor + 1
} else {
lines += expandedLineCount(filtered[i])
}
}
}

Expand Down Expand Up @@ -437,14 +538,19 @@ func expandedLineCount(evt SyncEvent) int {

// renderChildren renders the expanded file list for a directory group.
// totalCount is the original number of files in the group (may exceed len(files)).
func (m DashboardModel) renderChildren(files []string, totalCount int, nameWidth int) []string {
// focusedChild is the index of the focused child (-1 if none).
func (m DashboardModel) renderChildren(files []string, totalCount int, nameWidth int, focusedChild 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)
var lines []string
for _, f := range files {
for i, f := range files {
name := abbreviatePath(f, nameWidth-2)
lines = append(lines, prefix+"└ "+dimStyle.Render(name))
if i == focusedChild {
lines = append(lines, prefix+"> "+focusedStyle.Render(name))
} else {
lines = append(lines, prefix+" "+dimStyle.Render(name))
}
}
if remaining := totalCount - len(files); remaining > 0 {
lines = append(lines, prefix+dimStyle.Render(fmt.Sprintf(" +%d more", remaining)))
Expand Down
103 changes: 103 additions & 0 deletions internal/tui/dashboard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package tui

import "testing"

func TestMoveDownIntoChildren(t *testing.T) {
m := NewDashboard("/tmp/local", "remote:/tmp")
m.events = []SyncEvent{
{File: "src/", Status: "synced", Files: []string{"src/a.go", "src/b.go"}, FileCount: 2},
{File: "docs/", Status: "synced", Files: []string{"docs/readme.md"}, FileCount: 1},
}
m.expanded[0] = true

filtered := m.filteredEvents()

// Start at event 0, parent
m.cursor = 0
m.childCursor = -1

// j → first child
m.moveDown(filtered)
if m.cursor != 0 || m.childCursor != 0 {
t.Fatalf("expected cursor=0 child=0, got cursor=%d child=%d", m.cursor, m.childCursor)
}

// j → second child
m.moveDown(filtered)
if m.cursor != 0 || m.childCursor != 1 {
t.Fatalf("expected cursor=0 child=1, got cursor=%d child=%d", m.cursor, m.childCursor)
}

// j → next event
m.moveDown(filtered)
if m.cursor != 1 || m.childCursor != -1 {
t.Fatalf("expected cursor=1 child=-1, got cursor=%d child=%d", m.cursor, m.childCursor)
}
}

func TestMoveUpFromChildren(t *testing.T) {
m := NewDashboard("/tmp/local", "remote:/tmp")
m.events = []SyncEvent{
{File: "src/", Status: "synced", Files: []string{"src/a.go", "src/b.go"}, FileCount: 2},
{File: "docs/", Status: "synced", Files: []string{"docs/readme.md"}, FileCount: 1},
}
m.expanded[0] = true

filtered := m.filteredEvents()

// Start at event 1
m.cursor = 1
m.childCursor = -1

// k → last child of event 0
m.moveUp(filtered)
if m.cursor != 0 || m.childCursor != 1 {
t.Fatalf("expected cursor=0 child=1, got cursor=%d child=%d", m.cursor, m.childCursor)
}

// k → first child
m.moveUp(filtered)
if m.cursor != 0 || m.childCursor != 0 {
t.Fatalf("expected cursor=0 child=0, got cursor=%d child=%d", m.cursor, m.childCursor)
}

// k → parent
m.moveUp(filtered)
if m.cursor != 0 || m.childCursor != -1 {
t.Fatalf("expected cursor=0 child=-1, got cursor=%d child=%d", m.cursor, m.childCursor)
}
}

func TestMoveDownSkipsCollapsed(t *testing.T) {
m := NewDashboard("/tmp/local", "remote:/tmp")
m.events = []SyncEvent{
{File: "src/", Status: "synced", Files: []string{"src/a.go"}, FileCount: 1},
{File: "docs/", Status: "synced"},
}
// Not expanded — should skip children

filtered := m.filteredEvents()
m.cursor = 0
m.childCursor = -1

m.moveDown(filtered)
if m.cursor != 1 || m.childCursor != -1 {
t.Fatalf("expected cursor=1 child=-1, got cursor=%d child=%d", m.cursor, m.childCursor)
}
}

func TestMoveDownAtEnd(t *testing.T) {
m := NewDashboard("/tmp/local", "remote:/tmp")
m.events = []SyncEvent{
{File: "a.go", Status: "synced"},
}
filtered := m.filteredEvents()
m.cursor = 0
m.childCursor = -1

m.moveDown(filtered)
// Should stay at 0
if m.cursor != 0 {
t.Fatalf("expected cursor=0, got %d", m.cursor)
}
}