Skip to main content

git_same/tui/
backend.rs

1//! Backend integration — bridges TUI with existing async command handlers.
2//!
3//! Provides channel-based progress adapters and spawn functions for operations.
4
5use std::path::Path;
6use std::sync::Arc;
7use tokio::sync::mpsc::UnboundedSender;
8
9use crate::config::{Config, WorkspaceConfig, WorkspaceProvider};
10use crate::git::{FetchResult, GitOperations, PullResult, ShellGit};
11use crate::operations::clone::CloneProgress;
12use crate::operations::sync::SyncProgress;
13use crate::provider::DiscoveryProgress;
14use crate::types::{OpSummary, OwnedRepo};
15use crate::workflows::status_scan::scan_workspace_status;
16use crate::workflows::sync_workspace::{
17    execute_prepared_sync, prepare_sync_workspace, SyncWorkspaceRequest,
18};
19
20use super::app::{App, Operation};
21use super::event::{AppEvent, BackendMessage};
22
23// -- Progress adapters that send events to the TUI via channels --
24
25struct TuiDiscoveryProgress {
26    tx: UnboundedSender<AppEvent>,
27}
28
29impl DiscoveryProgress for TuiDiscoveryProgress {
30    fn on_orgs_discovered(&self, count: usize) {
31        let _ = self
32            .tx
33            .send(AppEvent::Backend(BackendMessage::OrgsDiscovered(count)));
34    }
35
36    fn on_org_started(&self, org_name: &str) {
37        let _ = self.tx.send(AppEvent::Backend(BackendMessage::OrgStarted(
38            org_name.to_string(),
39        )));
40    }
41
42    fn on_org_complete(&self, org_name: &str, repo_count: usize) {
43        let _ = self.tx.send(AppEvent::Backend(BackendMessage::OrgComplete(
44            org_name.to_string(),
45            repo_count,
46        )));
47    }
48
49    fn on_personal_repos_started(&self) {}
50
51    fn on_personal_repos_complete(&self, _count: usize) {}
52
53    fn on_error(&self, message: &str) {
54        let _ = self
55            .tx
56            .send(AppEvent::Backend(BackendMessage::DiscoveryError(
57                message.to_string(),
58            )));
59    }
60}
61
62struct TuiCloneProgress {
63    tx: UnboundedSender<AppEvent>,
64}
65
66impl CloneProgress for TuiCloneProgress {
67    fn on_start(&self, repo: &OwnedRepo, _index: usize, _total: usize) {
68        let _ = self.tx.send(AppEvent::Backend(BackendMessage::RepoStarted {
69            repo_name: repo.full_name().to_string(),
70        }));
71    }
72
73    fn on_complete(&self, repo: &OwnedRepo, _index: usize, _total: usize) {
74        let _ = self
75            .tx
76            .send(AppEvent::Backend(BackendMessage::RepoProgress {
77                repo_name: repo.full_name().to_string(),
78                success: true,
79                skipped: false,
80                message: "cloned".to_string(),
81                had_updates: true,
82                is_clone: true,
83                new_commits: None,
84                skip_reason: None,
85            }));
86    }
87
88    fn on_error(&self, repo: &OwnedRepo, error: &str, _index: usize, _total: usize) {
89        let _ = self
90            .tx
91            .send(AppEvent::Backend(BackendMessage::RepoProgress {
92                repo_name: repo.full_name().to_string(),
93                success: false,
94                skipped: false,
95                message: error.to_string(),
96                had_updates: false,
97                is_clone: true,
98                new_commits: None,
99                skip_reason: None,
100            }));
101    }
102
103    fn on_skip(&self, repo: &OwnedRepo, reason: &str, _index: usize, _total: usize) {
104        let _ = self
105            .tx
106            .send(AppEvent::Backend(BackendMessage::RepoProgress {
107                repo_name: repo.full_name().to_string(),
108                success: true,
109                skipped: true,
110                message: format!("skipped: {}", reason),
111                had_updates: false,
112                is_clone: true,
113                new_commits: None,
114                skip_reason: Some(reason.to_string()),
115            }));
116    }
117}
118
119struct TuiSyncProgress {
120    tx: UnboundedSender<AppEvent>,
121}
122
123impl SyncProgress for TuiSyncProgress {
124    fn on_start(&self, repo: &OwnedRepo, _path: &Path, _index: usize, _total: usize) {
125        let _ = self.tx.send(AppEvent::Backend(BackendMessage::RepoStarted {
126            repo_name: repo.full_name().to_string(),
127        }));
128    }
129
130    fn on_fetch_complete(
131        &self,
132        repo: &OwnedRepo,
133        result: &FetchResult,
134        _index: usize,
135        _total: usize,
136    ) {
137        let status = if result.updated {
138            "updated"
139        } else {
140            "up to date"
141        };
142        let _ = self
143            .tx
144            .send(AppEvent::Backend(BackendMessage::RepoProgress {
145                repo_name: repo.full_name().to_string(),
146                success: true,
147                skipped: false,
148                message: status.to_string(),
149                had_updates: result.updated,
150                is_clone: false,
151                new_commits: result.new_commits,
152                skip_reason: None,
153            }));
154    }
155
156    fn on_pull_complete(
157        &self,
158        repo: &OwnedRepo,
159        result: &PullResult,
160        _index: usize,
161        _total: usize,
162    ) {
163        let status = if result.fast_forward {
164            "fast-forward"
165        } else {
166            "pulled"
167        };
168        let _ = self
169            .tx
170            .send(AppEvent::Backend(BackendMessage::RepoProgress {
171                repo_name: repo.full_name().to_string(),
172                success: result.success,
173                skipped: false,
174                message: status.to_string(),
175                had_updates: result.success,
176                is_clone: false,
177                new_commits: None,
178                skip_reason: None,
179            }));
180    }
181
182    fn on_error(&self, repo: &OwnedRepo, error: &str, _index: usize, _total: usize) {
183        let _ = self
184            .tx
185            .send(AppEvent::Backend(BackendMessage::RepoProgress {
186                repo_name: repo.full_name().to_string(),
187                success: false,
188                skipped: false,
189                message: error.to_string(),
190                had_updates: false,
191                is_clone: false,
192                new_commits: None,
193                skip_reason: None,
194            }));
195    }
196
197    fn on_skip(&self, repo: &OwnedRepo, reason: &str, _index: usize, _total: usize) {
198        let _ = self
199            .tx
200            .send(AppEvent::Backend(BackendMessage::RepoProgress {
201                repo_name: repo.full_name().to_string(),
202                success: true,
203                skipped: true,
204                message: format!("skipped: {}", reason),
205                had_updates: false,
206                is_clone: false,
207                new_commits: None,
208                skip_reason: Some(reason.to_string()),
209            }));
210    }
211}
212
213// -- Spawn functions --
214
215/// Spawn an async task to fetch recent commits for a repo (post-sync deep dive).
216pub fn spawn_commit_fetch(
217    repo_path: std::path::PathBuf,
218    repo_name: String,
219    tx: UnboundedSender<AppEvent>,
220) {
221    tokio::spawn(async move {
222        let commits = tokio::task::spawn_blocking(move || {
223            let git = ShellGit::new();
224            git.recent_commits(&repo_path, 30).unwrap_or_default()
225        })
226        .await
227        .unwrap_or_default();
228
229        let _ = tx.send(AppEvent::Backend(BackendMessage::RepoCommitLog {
230            repo_name,
231            commits,
232        }));
233    });
234}
235
236/// Spawn commit fetches for multiple repos (aggregate changelog).
237pub fn spawn_changelog_fetch(
238    repos: Vec<(String, std::path::PathBuf)>,
239    tx: UnboundedSender<AppEvent>,
240) {
241    for (repo_name, repo_path) in repos {
242        let tx = tx.clone();
243        tokio::spawn(async move {
244            let commits = tokio::task::spawn_blocking(move || {
245                let git = ShellGit::new();
246                git.recent_commits(&repo_path, 30).unwrap_or_default()
247            })
248            .await
249            .unwrap_or_default();
250
251            let _ = tx.send(AppEvent::Backend(BackendMessage::RepoCommitLog {
252                repo_name,
253                commits,
254            }));
255        });
256    }
257}
258
259/// Spawn setup-wizard org discovery without blocking the TUI event loop.
260pub fn spawn_setup_org_discovery(
261    ws_provider: WorkspaceProvider,
262    token: String,
263    tx: UnboundedSender<AppEvent>,
264) {
265    tokio::spawn(async move {
266        match crate::setup::handler::discover_org_entries(ws_provider, token).await {
267            Ok(orgs) => {
268                let _ = tx.send(AppEvent::Backend(BackendMessage::SetupOrgsDiscovered(orgs)));
269            }
270            Err(err) => {
271                let _ = tx.send(AppEvent::Backend(BackendMessage::SetupOrgsError(err)));
272            }
273        }
274    });
275}
276
277/// Spawn a backend operation as a Tokio task.
278pub fn spawn_operation(operation: Operation, app: &App, tx: UnboundedSender<AppEvent>) {
279    let config = app.config.clone();
280    let workspace = app.active_workspace.clone();
281    let sync_pull = app.sync_pull;
282
283    match operation {
284        Operation::Sync => {
285            tokio::spawn(async move {
286                run_sync_operation(config, workspace, tx, sync_pull).await;
287            });
288        }
289        Operation::Status => {
290            let workspace = app.active_workspace.clone();
291            let config = app.config.clone();
292            tokio::spawn(async move {
293                run_status_scan(config, workspace, tx).await;
294            });
295        }
296    }
297}
298
299/// Combined sync operation: discover → clone new → fetch/pull existing.
300async fn run_sync_operation(
301    config: Config,
302    workspace: Option<WorkspaceConfig>,
303    tx: UnboundedSender<AppEvent>,
304    pull_mode: bool,
305) {
306    let workspace = match workspace {
307        Some(ws) => ws,
308        None => {
309            let _ = tx.send(AppEvent::Backend(BackendMessage::OperationError(
310                "No workspace selected. Run 'gisa setup' to configure one.".to_string(),
311            )));
312            return;
313        }
314    };
315
316    let discovery_progress = TuiDiscoveryProgress { tx: tx.clone() };
317    let prepared = match prepare_sync_workspace(
318        SyncWorkspaceRequest {
319            config: &config,
320            workspace: &workspace,
321            refresh: true,
322            skip_uncommitted: true,
323            pull: pull_mode,
324            concurrency_override: None,
325            create_base_path: true,
326        },
327        &discovery_progress,
328    )
329    .await
330    {
331        Ok(p) => p,
332        Err(e) => {
333            let _ = tx.send(AppEvent::Backend(BackendMessage::OperationError(format!(
334                "{}",
335                e
336            ))));
337            return;
338        }
339    };
340
341    // Send discovery results to populate org browser
342    let _ = tx.send(AppEvent::Backend(BackendMessage::DiscoveryComplete(
343        prepared.repos.clone(),
344    )));
345
346    if prepared.repos.is_empty() {
347        let _ = tx.send(AppEvent::Backend(BackendMessage::OperationComplete(
348            OpSummary::new(),
349        )));
350        return;
351    }
352
353    // Send OperationStarted so the UI transitions to Running state
354    let clone_count = prepared.plan.to_clone.len();
355    let sync_count = prepared.to_sync.len();
356    let total = clone_count + sync_count;
357    let _ = tx.send(AppEvent::Backend(BackendMessage::OperationStarted {
358        operation: Operation::Sync,
359        total,
360        to_clone: clone_count,
361        to_sync: sync_count,
362    }));
363
364    let clone_progress: Arc<dyn CloneProgress> = Arc::new(TuiCloneProgress { tx: tx.clone() });
365    let sync_progress: Arc<dyn SyncProgress> = Arc::new(TuiSyncProgress { tx: tx.clone() });
366    let outcome = execute_prepared_sync(&prepared, false, clone_progress, sync_progress).await;
367
368    let mut combined_summary = OpSummary::new();
369    if let Some(summary) = outcome.clone_summary {
370        combined_summary.success += summary.success;
371        combined_summary.failed += summary.failed;
372        combined_summary.skipped += summary.skipped;
373    }
374    if let Some(summary) = outcome.sync_summary {
375        combined_summary.success += summary.success;
376        combined_summary.failed += summary.failed;
377        combined_summary.skipped += summary.skipped;
378    }
379
380    let _ = tx.send(AppEvent::Backend(BackendMessage::OperationComplete(
381        combined_summary,
382    )));
383}
384
385/// Scans local repositories and gets their git status.
386async fn run_status_scan(
387    config: Config,
388    workspace: Option<WorkspaceConfig>,
389    tx: UnboundedSender<AppEvent>,
390) {
391    let workspace = match workspace {
392        Some(ws) => ws,
393        None => {
394            let _ = tx.send(AppEvent::Backend(BackendMessage::OperationError(
395                "No workspace selected.".to_string(),
396            )));
397            return;
398        }
399    };
400
401    let entries = tokio::task::spawn_blocking(move || scan_workspace_status(&config, &workspace))
402        .await
403        .unwrap_or_default();
404
405    let _ = tx.send(AppEvent::Backend(BackendMessage::StatusResults(entries)));
406}
407
408#[cfg(test)]
409#[path = "backend_tests.rs"]
410mod tests;