Coverage Report

Created: 2026-04-05 07:19

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
crates/flotilla-tui/src/widgets/delete_confirm.rs
Line
Count
Source
1
use std::path::PathBuf;
2
3
use flotilla_protocol::{CheckoutSelector, CheckoutStatus, Command, CommandAction, HostName, RepoSelector, WorkItemIdentity};
4
use ratatui::{
5
    layout::Rect,
6
    style::Style,
7
    text::{Line, Span},
8
    widgets::{Block, Clear, Paragraph, Wrap},
9
    Frame,
10
};
11
12
use super::{InteractiveWidget, Outcome, RenderContext, WidgetContext};
13
use crate::{
14
    app::ui_state::PendingActionContext,
15
    binding_table::{BindingModeId, KeyBindingMode, StatusContent, StatusFragment},
16
    keymap::Action,
17
    shimmer::shimmer_spans,
18
    ui_helpers,
19
};
20
21
pub struct DeleteConfirmWidget {
22
    pub info: Option<CheckoutStatus>,
23
    pub loading: bool,
24
    pub identity: WorkItemIdentity,
25
    pub remote_host: Option<HostName>,
26
    pub checkout_path: Option<PathBuf>,
27
}
28
29
impl DeleteConfirmWidget {
30
35
    pub fn new(identity: WorkItemIdentity, remote_host: Option<HostName>, checkout_path: Option<PathBuf>) -> Self {
31
35
        Self { info: None, loading: true, identity, remote_host, checkout_path }
32
35
    }
33
34
    /// Update the checkout status info after the async fetch completes.
35
21
    pub fn update_info(&mut self, status: CheckoutStatus) {
36
21
        self.info = Some(status);
37
21
        self.loading = false;
38
21
    }
39
}
40
41
impl InteractiveWidget for DeleteConfirmWidget {
42
19
    fn handle_action(&mut self, action: Action, ctx: &mut WidgetContext) -> Outcome {
43
19
        match action {
44
            Action::Confirm => {
45
14
                if self.loading {
46
2
                    return Outcome::Consumed;
47
12
                }
48
12
                if let Some(
ref info10
) = self.info {
49
10
                    let pending_ctx = PendingActionContext {
50
10
                        identity: self.identity.clone(),
51
10
                        description: format!("Remove {}", info.branch),
52
10
                        repo_identity: ctx.model.active_repo_identity().clone(),
53
10
                    };
54
10
                    let checkout = match &self.checkout_path {
55
2
                        Some(path) => CheckoutSelector::Path(path.clone()),
56
8
                        None => CheckoutSelector::Query(info.branch.clone()),
57
                    };
58
10
                    let action = CommandAction::RemoveCheckout { checkout };
59
10
                    let command = Command {
60
10
                        host: self.remote_host.clone(),
61
10
                        provisioning_target: None,
62
10
                        context_repo: Some(RepoSelector::Identity(ctx.model.active_repo_identity().clone())),
63
10
                        action,
64
10
                    };
65
10
                    ctx.commands.push_with_context(command, Some(pending_ctx));
66
2
                }
67
12
                Outcome::Finished
68
            }
69
4
            Action::Dismiss => Outcome::Finished,
70
1
            _ => Outcome::Ignored,
71
        }
72
19
    }
73
74
6
    fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &mut RenderContext) {
75
6
        let popup = ui_helpers::popup_area(area, 60, 50);
76
6
        frame.render_widget(Clear, popup);
77
78
6
        let theme = ctx.theme;
79
6
        let mut lines: Vec<Line> = Vec::new();
80
81
        const MAX_FILES: usize = 10;
82
        const MAX_COMMITS: usize = 5;
83
84
6
        if self.loading {
85
0
            lines.push(Line::from(shimmer_spans("Loading safety info...", theme)));
86
6
        } else if let Some(ref info) = self.info {
87
6
            lines.push(Line::from(vec![Span::raw("Branch: "), Span::styled(&info.branch, Style::default().bold())]));
88
6
            lines.push(Line::from(""));
89
90
6
            if let Some(
pr_status3
) = &info.change_request_status {
91
3
                let color = theme.change_request_status_color(pr_status);
92
3
                let status_text = pr_status.as_str();
93
3
                lines.push(Line::from(vec![
94
3
                    Span::raw(format!("{}: ", ctx.model.active_labels().change_requests.abbr)),
95
3
                    Span::styled(status_text, Style::default().fg(color).bold()),
96
                ]));
97
3
                if let Some(
sha2
) = &info.merge_commit_sha {
98
2
                    lines.push(Line::from(format!("Merge commit: {}", sha)));
99
2
                
}1
100
3
            } else {
101
3
                lines.push(Line::from(Span::styled(
102
3
                    format!("No {} found", ctx.model.active_labels().change_requests.abbr),
103
3
                    Style::default().fg(theme.muted),
104
3
                )));
105
3
            }
106
107
6
            lines.push(Line::from(""));
108
109
6
            if info.has_uncommitted {
110
2
                if info.uncommitted_files.is_empty() {
111
0
                    lines.push(Line::from(Span::styled("\u{26a0} Has uncommitted changes", Style::default().fg(theme.error).bold())));
112
0
                } else {
113
2
                    lines.push(Line::from(Span::styled(
114
2
                        format!("\u{26a0} {} uncommitted file(s):", info.uncommitted_files.len()),
115
2
                        Style::default().fg(theme.error).bold(),
116
                    )));
117
13
                    for file_line in 
info.uncommitted_files.iter()2
.
take2
(MAX_FILES) {
118
13
                        lines.push(Line::from(Span::styled(file_line.to_string(), Style::default().fg(theme.muted))));
119
13
                    }
120
2
                    if info.uncommitted_files.len() > MAX_FILES {
121
1
                        lines.push(Line::from(Span::styled(
122
1
                            format!("...and {} more", info.uncommitted_files.len() - MAX_FILES),
123
1
                            Style::default().fg(theme.muted),
124
1
                        )));
125
1
                    }
126
                }
127
4
            }
128
129
6
            if let Some(
warning1
) = &info.base_detection_warning {
130
1
                lines.push(Line::from(Span::styled(format!("\u{26a0} {}", warning), Style::default().fg(theme.warning))));
131
5
            } else if !info.unpushed_commits.is_empty() {
132
1
                lines.push(Line::from(Span::styled(
133
1
                    format!("\u{26a0} {} unpushed commit(s):", info.unpushed_commits.len()),
134
1
                    Style::default().fg(theme.error).bold(),
135
                )));
136
1
                for commit in info.unpushed_commits.iter().take(MAX_COMMITS) {
137
1
                    lines.push(Line::from(commit.to_string()));
138
1
                }
139
4
            }
140
141
6
            if !info.has_uncommitted
142
4
                && info.unpushed_commits.is_empty()
143
4
                && info.base_detection_warning.is_none()
144
3
                && info.change_request_status.as_ref().is_some_and(|s| 
s2
.
eq_ignore_ascii_case2
(
"merged"2
))
145
2
            {
146
2
                lines.push(Line::from(""));
147
2
                lines.push(Line::from(Span::styled("\u{2713} Safe to delete", Style::default().fg(theme.status_ok).bold())));
148
4
            }
149
150
6
            lines.push(Line::from(""));
151
6
            lines.push(Line::from(Span::styled("y/Enter: confirm    n/Esc: cancel", Style::default().fg(theme.muted))));
152
0
        }
153
154
6
        let title = match &self.remote_host {
155
2
            Some(host) => format!(" Remove {} on {} ", ctx.model.active_labels().checkouts.noun_capitalized(), host),
156
4
            None => format!(" Remove {} ", ctx.model.active_labels().checkouts.noun_capitalized()),
157
        };
158
6
        let paragraph = Paragraph::new(lines).block(Block::bordered().style(theme.block_style()).title(title)).wrap(Wrap { trim: true });
159
6
        frame.render_widget(paragraph, popup);
160
6
    }
161
162
25
    fn binding_mode(&self) -> KeyBindingMode {
163
25
        BindingModeId::DeleteConfirm.into()
164
25
    }
165
166
6
    fn status_fragment(&self) -> StatusFragment {
167
6
        StatusFragment { status: Some(StatusContent::Label("CONFIRM DELETE".into())) }
168
6
    }
169
170
2
    fn as_any(&self) -> &dyn std::any::Any {
171
2
        self
172
2
    }
173
174
8
    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
175
8
        self
176
8
    }
177
}
178
179
#[cfg(test)]
180
mod tests {
181
    use flotilla_protocol::{CheckoutStatus, CommandAction, HostName, WorkItemIdentity};
182
183
    use super::*;
184
    use crate::app::test_support::TestWidgetHarness;
185
186
8
    fn make_widget() -> DeleteConfirmWidget {
187
8
        DeleteConfirmWidget::new(WorkItemIdentity::Session("test".into()), None, None)
188
8
    }
189
190
4
    fn make_widget_with_info(branch: &str) -> DeleteConfirmWidget {
191
4
        let mut widget = make_widget();
192
4
        widget.update_info(CheckoutStatus {
193
4
            branch: branch.into(),
194
4
            change_request_status: None,
195
4
            merge_commit_sha: None,
196
4
            unpushed_commits: vec![],
197
4
            has_uncommitted: false,
198
4
            uncommitted_files: vec![],
199
4
            base_detection_warning: None,
200
4
        });
201
4
        widget
202
4
    }
203
204
    #[test]
205
1
    fn binding_mode_is_delete_confirm() {
206
1
        let widget = make_widget();
207
1
        assert_eq!(widget.binding_mode(), KeyBindingMode::from(BindingModeId::DeleteConfirm));
208
1
    }
209
210
    #[test]
211
1
    fn delete_confirm_y_sends_remove_checkout() {
212
1
        let mut widget = make_widget_with_info("feat/x");
213
1
        let mut harness = TestWidgetHarness::new();
214
1
        let mut ctx = harness.ctx();
215
216
1
        let outcome = widget.handle_action(Action::Confirm, &mut ctx);
217
1
        assert!(
matches!0
(outcome, Outcome::Finished));
218
219
1
        let (cmd, _) = harness.commands.take_next().expect("expected command");
220
1
        match cmd {
221
1
            Command { action: CommandAction::RemoveCheckout { checkout, .. }, .. } => {
222
1
                assert_eq!(checkout, CheckoutSelector::Query("feat/x".into()));
223
            }
224
0
            other => panic!("expected RemoveCheckout, got {:?}", other),
225
        }
226
1
    }
227
228
    #[test]
229
1
    fn delete_confirm_enter_sends_remove_checkout() {
230
1
        let mut widget = make_widget_with_info("feat/y");
231
1
        let mut harness = TestWidgetHarness::new();
232
1
        let mut ctx = harness.ctx();
233
234
1
        let outcome = widget.handle_action(Action::Confirm, &mut ctx);
235
1
        assert!(
matches!0
(outcome, Outcome::Finished));
236
237
1
        let (cmd, _) = harness.commands.take_next().expect("expected command");
238
1
        match cmd {
239
1
            Command { action: CommandAction::RemoveCheckout { checkout, .. }, .. } => {
240
1
                assert_eq!(checkout, CheckoutSelector::Query("feat/y".into()));
241
            }
242
0
            other => panic!("expected RemoveCheckout, got {:?}", other),
243
        }
244
1
    }
245
246
    #[test]
247
1
    fn delete_confirm_attaches_pending_context() {
248
1
        let identity = WorkItemIdentity::Session("custom-id".into());
249
1
        let mut widget = DeleteConfirmWidget::new(identity.clone(), None, None);
250
1
        widget.update_info(CheckoutStatus {
251
1
            branch: "feat/a".into(),
252
1
            change_request_status: None,
253
1
            merge_commit_sha: None,
254
1
            unpushed_commits: vec![],
255
1
            has_uncommitted: false,
256
1
            uncommitted_files: vec![],
257
1
            base_detection_warning: None,
258
1
        });
259
1
        let mut harness = TestWidgetHarness::new();
260
1
        let mut ctx = harness.ctx();
261
262
1
        widget.handle_action(Action::Confirm, &mut ctx);
263
264
1
        let (_, ctx) = harness.commands.take_next().expect("should have command");
265
1
        let ctx = ctx.expect("should have pending context");
266
1
        assert_eq!(ctx.identity, identity);
267
1
    }
268
269
    #[test]
270
1
    fn delete_confirm_routes_to_remote_host_when_set() {
271
1
        let hostname = HostName::new("feta");
272
1
        let mut widget = DeleteConfirmWidget::new(WorkItemIdentity::Session("test".into()), Some(hostname.clone()), None);
273
1
        widget.update_info(CheckoutStatus {
274
1
            branch: "feat/x".into(),
275
1
            change_request_status: None,
276
1
            merge_commit_sha: None,
277
1
            unpushed_commits: vec![],
278
1
            has_uncommitted: false,
279
1
            uncommitted_files: vec![],
280
1
            base_detection_warning: None,
281
1
        });
282
1
        let mut harness = TestWidgetHarness::new();
283
1
        let mut ctx = harness.ctx();
284
285
1
        widget.handle_action(Action::Confirm, &mut ctx);
286
287
1
        let (cmd, _) = harness.commands.take_next().expect("command");
288
1
        assert_eq!(cmd.host, Some(hostname));
289
1
        assert!(
matches!0
(cmd.action, CommandAction::RemoveCheckout { .. }));
290
1
    }
291
292
    #[test]
293
1
    fn delete_confirm_ignores_while_loading() {
294
1
        let mut widget = make_widget(); // loading=true, info=None
295
1
        let mut harness = TestWidgetHarness::new();
296
1
        let mut ctx = harness.ctx();
297
298
1
        let outcome = widget.handle_action(Action::Confirm, &mut ctx);
299
1
        assert!(
matches!0
(outcome, Outcome::Consumed));
300
1
        assert!(harness.commands.take_next().is_none());
301
1
    }
302
303
    #[test]
304
1
    fn delete_confirm_esc_cancels() {
305
1
        let mut widget = make_widget_with_info("feat/z");
306
1
        let mut harness = TestWidgetHarness::new();
307
1
        let mut ctx = harness.ctx();
308
309
1
        let outcome = widget.handle_action(Action::Dismiss, &mut ctx);
310
1
        assert!(
matches!0
(outcome, Outcome::Finished));
311
1
        assert!(harness.commands.take_next().is_none());
312
1
    }
313
314
    #[test]
315
1
    fn delete_confirm_n_cancels() {
316
1
        let mut widget = make_widget_with_info("feat/z");
317
1
        let mut harness = TestWidgetHarness::new();
318
1
        let mut ctx = harness.ctx();
319
320
        // 'n' resolves to Dismiss in the keymap for DeleteConfirm mode
321
1
        let outcome = widget.handle_action(Action::Dismiss, &mut ctx);
322
1
        assert!(
matches!0
(outcome, Outcome::Finished));
323
1
        assert!(harness.commands.take_next().is_none());
324
1
    }
325
326
    #[test]
327
1
    fn delete_confirm_y_with_no_info_does_not_push_command() {
328
1
        let mut widget = make_widget();
329
1
        widget.loading = false; // not loading, but no info either
330
1
        let mut harness = TestWidgetHarness::new();
331
1
        let mut ctx = harness.ctx();
332
333
1
        let outcome = widget.handle_action(Action::Confirm, &mut ctx);
334
1
        assert!(
matches!0
(outcome, Outcome::Finished));
335
1
        assert!(harness.commands.take_next().is_none());
336
1
    }
337
338
    #[test]
339
1
    fn delete_confirm_uses_path_selector_when_checkout_path_set() {
340
1
        let mut widget = DeleteConfirmWidget::new(WorkItemIdentity::Session("test".into()), None, Some(PathBuf::from("/tmp/feat-x")));
341
1
        widget.update_info(CheckoutStatus {
342
1
            branch: "feat/x".into(),
343
1
            change_request_status: None,
344
1
            merge_commit_sha: None,
345
1
            unpushed_commits: vec![],
346
1
            has_uncommitted: false,
347
1
            uncommitted_files: vec![],
348
1
            base_detection_warning: None,
349
1
        });
350
1
        let mut harness = TestWidgetHarness::new();
351
1
        let mut ctx = harness.ctx();
352
353
1
        widget.handle_action(Action::Confirm, &mut ctx);
354
355
1
        let (cmd, _) = harness.commands.take_next().expect("expected command");
356
1
        match cmd {
357
1
            Command { action: CommandAction::RemoveCheckout { checkout, .. }, .. } => {
358
1
                assert_eq!(checkout, CheckoutSelector::Path(PathBuf::from("/tmp/feat-x")));
359
            }
360
0
            other => panic!("expected RemoveCheckout, got {:?}", other),
361
        }
362
1
    }
363
364
    #[test]
365
1
    fn unhandled_action_returns_ignored() {
366
1
        let mut widget = make_widget();
367
1
        let mut harness = TestWidgetHarness::new();
368
1
        let mut ctx = harness.ctx();
369
370
1
        let outcome = widget.handle_action(Action::Quit, &mut ctx);
371
1
        assert!(
matches!0
(outcome, Outcome::Ignored));
372
1
    }
373
}