esync

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

commit 66c9a65f269e00918596bc017147b487c472a329
parent 4b87514e176e443f899cc3a33a04ee4877caed7e
Author: Erik Loualiche <eloualiche@users.noreply.github.com>
Date:   Mon,  9 Mar 2026 18:12:53 -0400

Merge pull request #10 from LouLouLibs/feat/view-file

feat: view file from dashboard with v key
Diffstat:
M.gitignore | 1+
Minternal/tui/app.go | 22++++++++++++++++++++++
Minternal/tui/dashboard.go | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Ainternal/tui/dashboard_test.go | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 249 insertions(+), 17 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -22,3 +22,4 @@ test-sync tests /esync.toml sync.log +.worktrees/ diff --git a/internal/tui/app.go b/internal/tui/app.go @@ -1,6 +1,9 @@ package tui import ( + "os" + "os/exec" + tea "github.com/charmbracelet/bubbletea" ) @@ -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 // --------------------------------------------------------------------------- @@ -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 diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "path/filepath" "strings" "time" @@ -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 } @@ -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), } } @@ -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...) @@ -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": @@ -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) { @@ -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 } } } @@ -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 } @@ -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 { @@ -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 @@ -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) } @@ -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]) + } } } @@ -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))) diff --git a/internal/tui/dashboard_test.go b/internal/tui/dashboard_test.go @@ -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) + } +}