1use 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#[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 prompt_shown: bool,
34 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 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 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 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 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
198fn 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}