esync

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

app.go (9589B)


      1 package tui
      2 
      3 import (
      4 	"crypto/sha256"
      5 	"os"
      6 	"os/exec"
      7 
      8 	tea "github.com/charmbracelet/bubbletea"
      9 	"github.com/louloulibs/esync/internal/config"
     10 )
     11 
     12 // ---------------------------------------------------------------------------
     13 // View enum
     14 // ---------------------------------------------------------------------------
     15 
     16 type view int
     17 
     18 const (
     19 	viewDashboard view = iota
     20 	viewLogs
     21 )
     22 
     23 // SyncStatusMsg updates the header status without adding an event.
     24 type SyncStatusMsg string
     25 
     26 // ResyncRequestMsg signals that the user pressed 'r' for a full resync.
     27 type ResyncRequestMsg struct{}
     28 
     29 // OpenFileMsg signals that the user wants to open a file in their editor.
     30 type OpenFileMsg struct{ Path string }
     31 
     32 type editorFinishedMsg struct{ err error }
     33 
     34 // EditConfigMsg signals that the user wants to edit the config file.
     35 // Visual selects $VISUAL (GUI editor) instead of $EDITOR (terminal editor).
     36 type EditConfigMsg struct{ Visual bool }
     37 
     38 // editorConfigFinishedMsg is sent when the config editor exits.
     39 type editorConfigFinishedMsg struct{ err error }
     40 
     41 // ConfigReloadedMsg signals that the config was reloaded with new paths.
     42 type ConfigReloadedMsg struct {
     43 	Local  string
     44 	Remote string
     45 }
     46 
     47 // ---------------------------------------------------------------------------
     48 // AppModel — root Bubbletea model
     49 // ---------------------------------------------------------------------------
     50 
     51 // AppModel is the root Bubbletea model that switches between the dashboard
     52 // and log views.
     53 type AppModel struct {
     54 	dashboard  DashboardModel
     55 	logView    LogViewModel
     56 	current    view
     57 	syncEvents chan SyncEvent
     58 	logEntries chan LogEntry
     59 	resyncCh       chan struct{}
     60 	configReloadCh chan *config.Config
     61 
     62 	// Config editor state
     63 	configTempFile string
     64 	configChecksum [32]byte
     65 }
     66 
     67 // NewApp creates a new AppModel wired to the given local and remote paths.
     68 func NewApp(local, remote string) *AppModel {
     69 	return &AppModel{
     70 		dashboard:  NewDashboard(local, remote),
     71 		logView:    NewLogView(),
     72 		current:    viewDashboard,
     73 		syncEvents: make(chan SyncEvent, 64),
     74 		logEntries: make(chan LogEntry, 64),
     75 		resyncCh:       make(chan struct{}, 1),
     76 		configReloadCh: make(chan *config.Config, 1),
     77 	}
     78 }
     79 
     80 // SyncEventChan returns a send-only channel for pushing sync events into
     81 // the TUI from external code.
     82 func (m *AppModel) SyncEventChan() chan<- SyncEvent {
     83 	return m.syncEvents
     84 }
     85 
     86 // LogEntryChan returns a send-only channel for pushing log entries into
     87 // the TUI from external code.
     88 func (m *AppModel) LogEntryChan() chan<- LogEntry {
     89 	return m.logEntries
     90 }
     91 
     92 // ResyncChan returns a channel that receives when the user requests a full resync.
     93 func (m *AppModel) ResyncChan() <-chan struct{} {
     94 	return m.resyncCh
     95 }
     96 
     97 // ConfigReloadChan returns a channel that receives a new config when the user
     98 // edits and saves the config file from the TUI.
     99 func (m *AppModel) ConfigReloadChan() <-chan *config.Config {
    100 	return m.configReloadCh
    101 }
    102 
    103 // SetWarnings sets the warning count shown in the dashboard stats bar.
    104 // Call before Run() — no concurrency protection needed.
    105 func (m *AppModel) SetWarnings(n int) {
    106 	m.dashboard.totalWarnings = n
    107 }
    108 
    109 // ---------------------------------------------------------------------------
    110 // tea.Model interface
    111 // ---------------------------------------------------------------------------
    112 
    113 // Init returns a batch of the dashboard init command and the two channel
    114 // listener commands.
    115 func (m AppModel) Init() tea.Cmd {
    116 	return tea.Batch(
    117 		m.dashboard.Init(),
    118 		m.listenSyncEvents(),
    119 		m.listenLogEntries(),
    120 	)
    121 }
    122 
    123 // Update delegates messages to the active view and handles global keys.
    124 func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    125 	switch msg := msg.(type) {
    126 
    127 	case tea.KeyMsg:
    128 		// Global: quit from any view.
    129 		switch msg.String() {
    130 		case "q", "ctrl+c":
    131 			// Let the current view handle it if it's filtering.
    132 			if m.current == viewDashboard && m.dashboard.filtering {
    133 				break
    134 			}
    135 			if m.current == viewLogs && m.logView.filtering {
    136 				break
    137 			}
    138 			return m, tea.Quit
    139 		case "l":
    140 			// Toggle view (only when not filtering).
    141 			if m.current == viewDashboard && !m.dashboard.filtering {
    142 				m.current = viewLogs
    143 				return m, nil
    144 			}
    145 			if m.current == viewLogs && !m.logView.filtering {
    146 				m.current = viewDashboard
    147 				return m, nil
    148 			}
    149 		}
    150 
    151 	case SyncStatusMsg:
    152 		m.dashboard.status = string(msg)
    153 		return m, nil
    154 
    155 	case ResyncRequestMsg:
    156 		select {
    157 		case m.resyncCh <- struct{}{}:
    158 		default:
    159 		}
    160 		return m, nil
    161 
    162 	case OpenFileMsg:
    163 		editor := os.Getenv("EDITOR")
    164 		if editor == "" {
    165 			editor = "less"
    166 		}
    167 		c := exec.Command(editor, msg.Path)
    168 		return m, tea.ExecProcess(c, func(err error) tea.Msg {
    169 			return editorFinishedMsg{err}
    170 		})
    171 
    172 	case editorFinishedMsg:
    173 		// Editor exited; nothing to do on success.
    174 		return m, nil
    175 
    176 	case EditConfigMsg:
    177 		configPath := ".esync.toml"
    178 		var targetPath string
    179 
    180 		if _, err := os.Stat(configPath); err == nil {
    181 			// Existing file: checksum and edit in place
    182 			data, err := os.ReadFile(configPath)
    183 			if err != nil {
    184 				return m, nil
    185 			}
    186 			m.configChecksum = sha256.Sum256(data)
    187 			m.configTempFile = ""
    188 			targetPath = configPath
    189 		} else {
    190 			// New file: write template to temp file
    191 			tmpFile, err := os.CreateTemp("", "esync-*.toml")
    192 			if err != nil {
    193 				return m, nil
    194 			}
    195 			tmpl := config.EditTemplateTOML()
    196 			if _, err := tmpFile.WriteString(tmpl); err != nil {
    197 				tmpFile.Close()
    198 				os.Remove(tmpFile.Name())
    199 				return m, nil
    200 			}
    201 			tmpFile.Close()
    202 			m.configChecksum = sha256.Sum256([]byte(tmpl))
    203 			m.configTempFile = tmpFile.Name()
    204 			targetPath = tmpFile.Name()
    205 		}
    206 
    207 		editor := resolveEditor(msg.Visual)
    208 		c := exec.Command(editor, targetPath)
    209 		return m, tea.ExecProcess(c, func(err error) tea.Msg {
    210 			return editorConfigFinishedMsg{err}
    211 		})
    212 
    213 	case editorConfigFinishedMsg:
    214 		if msg.err != nil {
    215 			// Editor exited with error — discard
    216 			if m.configTempFile != "" {
    217 				os.Remove(m.configTempFile)
    218 				m.configTempFile = ""
    219 			}
    220 			return m, nil
    221 		}
    222 
    223 		configPath := ".esync.toml"
    224 		editedPath := configPath
    225 		if m.configTempFile != "" {
    226 			editedPath = m.configTempFile
    227 		}
    228 
    229 		data, err := os.ReadFile(editedPath)
    230 		if err != nil {
    231 			if m.configTempFile != "" {
    232 				os.Remove(m.configTempFile)
    233 				m.configTempFile = ""
    234 			}
    235 			return m, nil
    236 		}
    237 
    238 		newChecksum := sha256.Sum256(data)
    239 		if newChecksum == m.configChecksum {
    240 			// No changes
    241 			if m.configTempFile != "" {
    242 				os.Remove(m.configTempFile)
    243 				m.configTempFile = ""
    244 			}
    245 			return m, nil
    246 		}
    247 
    248 		// Changed — if temp, persist to .esync.toml
    249 		if m.configTempFile != "" {
    250 			if err := os.WriteFile(configPath, data, 0644); err != nil {
    251 				m.dashboard.status = "error: could not write " + configPath
    252 				os.Remove(m.configTempFile)
    253 				m.configTempFile = ""
    254 				return m, nil
    255 			}
    256 			os.Remove(m.configTempFile)
    257 			m.configTempFile = ""
    258 		}
    259 
    260 		// Parse the new config
    261 		cfg, err := config.Load(configPath)
    262 		if err != nil {
    263 			m.dashboard.status = "config error: " + err.Error()
    264 			return m, nil
    265 		}
    266 
    267 		// Send to reload channel (non-blocking)
    268 		select {
    269 		case m.configReloadCh <- cfg:
    270 		default:
    271 		}
    272 		return m, nil
    273 
    274 	case ConfigReloadedMsg:
    275 		m.updatePaths(msg.Local, msg.Remote)
    276 		return m, nil
    277 
    278 	case SyncEventMsg:
    279 		// Dispatch to dashboard and re-listen.
    280 		var cmd tea.Cmd
    281 		m.dashboard, cmd = m.dashboard.Update(msg)
    282 		return m, tea.Batch(cmd, m.listenSyncEvents())
    283 
    284 	case LogEntryMsg:
    285 		// Dispatch to log view and re-listen.
    286 		var cmd tea.Cmd
    287 		m.logView, cmd = m.logView.Update(msg)
    288 		return m, tea.Batch(cmd, m.listenLogEntries())
    289 
    290 	case tea.WindowSizeMsg:
    291 		// Propagate to both views.
    292 		m.dashboard, _ = m.dashboard.Update(msg)
    293 		m.logView, _ = m.logView.Update(msg)
    294 		return m, nil
    295 	}
    296 
    297 	// Delegate remaining messages to the active view.
    298 	switch m.current {
    299 	case viewDashboard:
    300 		var cmd tea.Cmd
    301 		m.dashboard, cmd = m.dashboard.Update(msg)
    302 		return m, cmd
    303 	case viewLogs:
    304 		var cmd tea.Cmd
    305 		m.logView, cmd = m.logView.Update(msg)
    306 		return m, cmd
    307 	}
    308 
    309 	return m, nil
    310 }
    311 
    312 // View renders the currently active view.
    313 func (m AppModel) View() string {
    314 	switch m.current {
    315 	case viewLogs:
    316 		return m.logView.View()
    317 	default:
    318 		return m.dashboard.View()
    319 	}
    320 }
    321 
    322 // ---------------------------------------------------------------------------
    323 // Channel listeners
    324 // ---------------------------------------------------------------------------
    325 
    326 // listenSyncEvents returns a Cmd that blocks until a SyncEvent arrives on
    327 // the channel, then wraps it as a SyncEventMsg.
    328 func (m AppModel) listenSyncEvents() tea.Cmd {
    329 	ch := m.syncEvents
    330 	return func() tea.Msg {
    331 		return SyncEventMsg(<-ch)
    332 	}
    333 }
    334 
    335 // listenLogEntries returns a Cmd that blocks until a LogEntry arrives on
    336 // the channel, then wraps it as a LogEntryMsg.
    337 func (m AppModel) listenLogEntries() tea.Cmd {
    338 	ch := m.logEntries
    339 	return func() tea.Msg {
    340 		return LogEntryMsg(<-ch)
    341 	}
    342 }
    343 
    344 // ---------------------------------------------------------------------------
    345 // Helpers
    346 // ---------------------------------------------------------------------------
    347 
    348 // resolveEditor returns the user's preferred editor.
    349 // When visual is true it checks $VISUAL first (GUI editor like BBEdit);
    350 // otherwise it uses $EDITOR (terminal editor like Helix), falling back to "vi".
    351 func resolveEditor(visual bool) string {
    352 	if visual {
    353 		if e := os.Getenv("VISUAL"); e != "" {
    354 			return e
    355 		}
    356 	}
    357 	if e := os.Getenv("EDITOR"); e != "" {
    358 		return e
    359 	}
    360 	return "vi"
    361 }
    362 
    363 // updatePaths updates the local and remote paths displayed in the dashboard.
    364 func (m *AppModel) updatePaths(local, remote string) {
    365 	m.dashboard.local = local
    366 	m.dashboard.remote = remote
    367 }