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 }