Skip to main content

atomcode_tuix/modals/
issue_wizard.rs

1// crates/atomcode-tuix/src/modals/issue_wizard.rs
2//
3// `/issue` modal — two-step wizard that collects a **new** AtomGit
4// issue's title + body, then hands them to the event loop's post-close
5// branch which POSTs `/api/v5/repos/{owner}/{repo}/issues` and echoes
6// the created issue URL into scrollback.
7//
8// (Unrelated to `/fixissue <url>`, which pulls an *existing* issue.)
9
10use anyhow::Result;
11use crossterm::event::{KeyCode, KeyModifiers};
12
13use super::{Modal, ModalAction};
14use crate::event_loop::{build_status, Buffer, LoopCtx, NewIssueDraft};
15use crate::render::{Renderer, UiLine};
16use crate::state::UiState;
17
18/// Which field is currently being edited.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20enum Step {
21    Title,
22    Description,
23}
24
25pub struct IssueWizard {
26    owner: String,
27    repo: String,
28    step: Step,
29    title: String,
30    /// True once `emit_prompt` has printed the step-1 ("Enter title")
31    /// header. Kept so step transitions only emit the step-2 header
32    /// after advancing (printing it on every redraw would duplicate).
33    prompt_shown: bool,
34    /// True once the step-2 ("Enter description") header has been
35    /// printed into scrollback. Matches `prompt_shown` in purpose.
36    desc_prompt_shown: bool,
37}
38
39impl IssueWizard {
40    pub fn open(owner: String, repo: String) -> Self {
41        Self {
42            owner,
43            repo,
44            step: Step::Title,
45            title: String::new(),
46            prompt_shown: false,
47            desc_prompt_shown: false,
48        }
49    }
50}
51
52impl Modal for IssueWizard {
53    fn handle_key(
54        &mut self,
55        code: KeyCode,
56        mods: KeyModifiers,
57        buf: &mut Buffer,
58        state: &mut UiState,
59        ctx: &mut LoopCtx,
60        renderer: &mut dyn Renderer,
61    ) -> Result<ModalAction> {
62        match code {
63            KeyCode::Esc => {
64                push(renderer, &crate::i18n::t(crate::i18n::Msg::IssueCancelled));
65                buf.text.clear();
66                buf.cursor = 0;
67                Ok(ModalAction::Close)
68            }
69            // Shift+Enter / Alt+Enter in the description step inserts a
70            // literal newline so users can write a multi-paragraph body.
71            // Step-1 (title) stays single-line: a newline in a title
72            // would look wrong on AtomGit anyway.
73            KeyCode::Enter
74                if self.step == Step::Description
75                    && (mods.contains(KeyModifiers::SHIFT) || mods.contains(KeyModifiers::ALT)) =>
76            {
77                buf.text.push('\n');
78                buf.cursor = buf.text.len();
79                self.draw(buf, state, ctx, renderer);
80                Ok(ModalAction::Continue)
81            }
82            KeyCode::Enter => {
83                let entered = buf.text.trim().to_string();
84                if entered.is_empty() {
85                    let what = match self.step {
86                        Step::Title => "title",
87                        Step::Description => "description",
88                    };
89                    push(
90                        renderer,
91                        &crate::i18n::t(crate::i18n::Msg::IssueRequiredField { field: what }),
92                    );
93                    return Ok(ModalAction::Continue);
94                }
95                buf.text.clear();
96                buf.cursor = 0;
97                match self.step {
98                    Step::Title => {
99                        self.title = entered;
100                        self.step = Step::Description;
101                        self.emit_description_prompt(renderer);
102                        self.draw(buf, state, ctx, renderer);
103                        Ok(ModalAction::Continue)
104                    }
105                    Step::Description => {
106                        // Signal the event loop: it will POST to AtomGit
107                        // and render the resulting URL back into the
108                        // conversation. Wizard is done.
109                        ctx.pending_new_issue = Some(NewIssueDraft {
110                            owner: self.owner.clone(),
111                            repo: self.repo.clone(),
112                            title: std::mem::take(&mut self.title),
113                            body: entered,
114                        });
115                        Ok(ModalAction::Close)
116                    }
117                }
118            }
119            KeyCode::Backspace => {
120                if !buf.text.is_empty() {
121                    let len = buf.text.len();
122                    // Pop one char (grapheme-aware is overkill for free-form text).
123                    let mut end = len;
124                    while end > 0 && !buf.text.is_char_boundary(end - 1) {
125                        end -= 1;
126                    }
127                    if end > 0 {
128                        buf.text.truncate(end - 1);
129                    }
130                    buf.cursor = buf.text.len();
131                    self.draw(buf, state, ctx, renderer);
132                }
133                Ok(ModalAction::Continue)
134            }
135            KeyCode::Char(c) => {
136                buf.text.push(c);
137                buf.cursor = buf.text.len();
138                self.draw(buf, state, ctx, renderer);
139                Ok(ModalAction::Continue)
140            }
141            _ => Ok(ModalAction::Continue),
142        }
143    }
144
145    fn draw(&self, buf: &Buffer, state: &UiState, ctx: &LoopCtx, renderer: &mut dyn Renderer) {
146        renderer.render(UiLine::InputPrompt {
147            buf: buf.text.clone(),
148            cursor_byte: buf.cursor,
149            menu: None,
150            status: build_status(state, ctx),
151            attachments: Vec::new(),
152        });
153        renderer.flush();
154    }
155}
156
157impl IssueWizard {
158    /// Called once by the event loop right after installing the wizard.
159    /// Prints the "which repo" + step-1 ("enter title") header into
160    /// scrollback so the user knows what's being asked.
161    pub fn emit_prompt(&mut self, renderer: &mut dyn Renderer) {
162        if self.prompt_shown {
163            return;
164        }
165        self.prompt_shown = true;
166        push(
167            renderer,
168            &crate::i18n::t(crate::i18n::Msg::IssueNewOn { owner: &self.owner, repo: &self.repo }),
169        );
170        push(
171            renderer,
172            &crate::i18n::t(crate::i18n::Msg::IssueStep1),
173        );
174    }
175
176    fn emit_description_prompt(&mut self, renderer: &mut dyn Renderer) {
177        if self.desc_prompt_shown {
178            return;
179        }
180        self.desc_prompt_shown = true;
181        push(renderer, "");
182        push(
183            renderer,
184            &crate::i18n::t(crate::i18n::Msg::IssueTitleConfirmed { title: &abbreviate(&self.title, 80) }),
185        );
186        push(
187            renderer,
188            &crate::i18n::t(crate::i18n::Msg::IssueStep2),
189        );
190    }
191}
192
193fn push(renderer: &mut dyn Renderer, text: &str) {
194    renderer.render(UiLine::CommandOutput(format!("  {}\n", text)));
195    renderer.flush();
196}
197
198/// Cap a line to `max` chars for display. Longer strings get a `…` tail
199/// so the step-2 "✓ title" summary never blows out one screen row.
200fn abbreviate(s: &str, max: usize) -> String {
201    if s.chars().count() <= max {
202        s.to_string()
203    } else {
204        let head: String = s.chars().take(max.saturating_sub(1)).collect();
205        format!("{}…", head)
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn open_starts_on_title_step() {
215        let w = IssueWizard::open("o".into(), "r".into());
216        assert_eq!(w.step, Step::Title);
217        assert!(w.title.is_empty());
218        assert!(!w.prompt_shown);
219    }
220
221    #[test]
222    fn abbreviate_short_string_unchanged() {
223        assert_eq!(abbreviate("hello", 80), "hello");
224    }
225
226    #[test]
227    fn abbreviate_long_string_tail_ellipsis() {
228        let long = "x".repeat(100);
229        let out = abbreviate(&long, 10);
230        assert!(out.ends_with('…'));
231        assert_eq!(out.chars().count(), 10);
232    }
233}