git_iris/studio/components/
message_editor.rs1use crate::studio::theme;
6use crate::studio::utils::truncate_width;
7use crate::types::GeneratedMessage;
8use crossterm::event::{KeyCode, KeyEvent};
9use ratatui::Frame;
10use ratatui::layout::Rect;
11use ratatui::style::{Modifier, Style};
12use ratatui::text::{Line, Span};
13use ratatui::widgets::{Block, Borders, Paragraph};
14use ratatui_textarea::TextArea;
15
16pub struct MessageEditorState {
22 textarea: TextArea<'static>,
24 generated_messages: Vec<GeneratedMessage>,
26 selected_message: usize,
28 edit_mode: bool,
30 original_message: String,
32}
33
34impl Default for MessageEditorState {
35 fn default() -> Self {
36 Self::new()
37 }
38}
39
40impl MessageEditorState {
41 #[must_use]
43 pub fn new() -> Self {
44 let mut textarea = TextArea::default();
45 textarea.set_cursor_line_style(Style::default().bg(theme::bg_highlight_color()));
46 textarea.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
47
48 Self {
49 textarea,
50 generated_messages: Vec::new(),
51 selected_message: 0,
52 edit_mode: false,
53 original_message: String::new(),
54 }
55 }
56
57 pub fn set_messages(&mut self, messages: Vec<GeneratedMessage>) {
59 self.generated_messages = messages;
60 self.selected_message = 0;
61 let first_msg = self.generated_messages.first().cloned();
62 if let Some(msg) = first_msg {
63 self.load_message(&msg);
64 }
65 }
66
67 pub fn add_messages(&mut self, messages: Vec<GeneratedMessage>) -> usize {
70 let first_new_index = self.generated_messages.len();
71 self.generated_messages.extend(messages);
72 self.selected_message = first_new_index;
73 if let Some(msg) = self.generated_messages.get(first_new_index).cloned() {
74 self.load_message(&msg);
75 }
76 first_new_index
77 }
78
79 fn load_message(&mut self, msg: &GeneratedMessage) {
81 let full_message = format_message(msg);
82 self.original_message.clone_from(&full_message);
83
84 self.textarea = TextArea::from(full_message.lines().map(String::from).collect::<Vec<_>>());
86 self.textarea
87 .set_cursor_line_style(Style::default().bg(theme::bg_highlight_color()));
88 self.textarea
89 .set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
90 }
91
92 #[must_use]
94 pub fn message_count(&self) -> usize {
95 self.generated_messages.len()
96 }
97
98 #[must_use]
100 pub fn selected_index(&self) -> usize {
101 self.selected_message
102 }
103
104 pub fn next_message(&mut self) {
106 if !self.generated_messages.is_empty() {
107 self.selected_message = (self.selected_message + 1) % self.generated_messages.len();
108 if let Some(msg) = self.generated_messages.get(self.selected_message) {
109 self.load_message(&msg.clone());
110 }
111 self.edit_mode = false;
112 }
113 }
114
115 pub fn prev_message(&mut self) {
117 if !self.generated_messages.is_empty() {
118 self.selected_message = if self.selected_message == 0 {
119 self.generated_messages.len() - 1
120 } else {
121 self.selected_message - 1
122 };
123 if let Some(msg) = self.generated_messages.get(self.selected_message) {
124 self.load_message(&msg.clone());
125 }
126 self.edit_mode = false;
127 }
128 }
129
130 pub fn enter_edit_mode(&mut self) {
132 self.edit_mode = true;
133 }
134
135 pub fn exit_edit_mode(&mut self) {
137 self.edit_mode = false;
138 }
139
140 pub fn is_editing(&self) -> bool {
142 self.edit_mode
143 }
144
145 pub fn reset(&mut self) {
147 self.textarea = TextArea::from(
148 self.original_message
149 .lines()
150 .map(String::from)
151 .collect::<Vec<_>>(),
152 );
153 self.textarea
154 .set_cursor_line_style(Style::default().bg(theme::bg_highlight_color()));
155 self.textarea
156 .set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
157 self.edit_mode = false;
158 }
159
160 pub fn clear(&mut self) {
162 self.generated_messages.clear();
163 self.selected_message = 0;
164 self.original_message.clear();
165 self.textarea = TextArea::default();
166 self.textarea
167 .set_cursor_line_style(Style::default().bg(theme::bg_highlight_color()));
168 self.textarea
169 .set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
170 self.edit_mode = false;
171 }
172
173 pub fn get_message(&self) -> String {
175 self.textarea.lines().join("\n")
176 }
177
178 pub fn current_generated(&self) -> Option<&GeneratedMessage> {
180 self.generated_messages.get(self.selected_message)
181 }
182
183 pub fn handle_key(&mut self, key: KeyEvent) -> bool {
185 if !self.edit_mode {
186 return false;
187 }
188
189 if let (KeyCode::Esc, _) = (key.code, key.modifiers) {
191 self.exit_edit_mode();
192 true
193 } else {
194 self.textarea.input(key);
196 true
197 }
198 }
199
200 pub fn is_modified(&self) -> bool {
202 self.get_message() != self.original_message
203 }
204
205 pub fn textarea(&self) -> &TextArea<'static> {
207 &self.textarea
208 }
209}
210
211#[must_use]
217pub fn format_message(msg: &GeneratedMessage) -> String {
218 let title = msg.subject();
219
220 if msg.message.is_empty() {
221 title
222 } else {
223 format!("{}\n\n{}", title, msg.message)
224 }
225}
226
227pub fn render_message_editor(
233 frame: &mut Frame,
234 area: Rect,
235 state: &MessageEditorState,
236 title: &str,
237 focused: bool,
238 generating: bool,
239 status_message: Option<&str>,
240) {
241 let count_indicator = if state.message_count() > 1 {
243 format!(
244 " ({}/{})",
245 state.selected_index() + 1,
246 state.message_count()
247 )
248 } else {
249 String::new()
250 };
251
252 let mode_indicator = if state.is_editing() { " [EDITING]" } else { "" };
253
254 let full_title = format!(" {}{}{} ", title, count_indicator, mode_indicator);
255
256 let block = Block::default()
257 .title(full_title)
258 .borders(Borders::ALL)
259 .border_style(if focused {
260 if state.is_editing() {
261 Style::default().fg(theme::accent_primary())
262 } else {
263 theme::focused_border()
264 }
265 } else {
266 theme::unfocused_border()
267 });
268
269 let inner = block.inner(area);
270 frame.render_widget(block, area);
271
272 if inner.height == 0 || inner.width == 0 {
273 return;
274 }
275
276 if state.message_count() == 0 {
277 let placeholder = if generating {
279 let spinner_frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
281 let frame_idx = (std::time::SystemTime::now()
282 .duration_since(std::time::UNIX_EPOCH)
283 .unwrap_or_default()
284 .as_millis()
285 / 100) as usize
286 % spinner_frames.len();
287 let spinner = spinner_frames[frame_idx];
288
289 let status_text = status_message.unwrap_or("Iris is crafting your commit message");
291
292 Paragraph::new(vec![
293 Line::from(""),
294 Line::from(vec![
295 Span::styled(
296 format!("{} ", spinner),
297 Style::default().fg(theme::accent_primary()),
298 ),
299 Span::styled(
300 status_text,
301 Style::default().fg(theme::text_primary_color()),
302 ),
303 ]),
304 ])
305 } else {
306 Paragraph::new(vec![
307 Line::from(Span::styled("No commit message generated", theme::dimmed())),
308 Line::from(""),
309 Line::from(Span::styled(
310 "Press 'r' to regenerate",
311 Style::default().fg(theme::accent_secondary()),
312 )),
313 ])
314 };
315 frame.render_widget(placeholder, inner);
316 } else if state.is_editing() {
317 frame.render_widget(state.textarea(), inner);
319 } else {
320 render_message_view(frame, inner, state);
322 }
323}
324
325fn render_message_view(frame: &mut Frame, area: Rect, state: &MessageEditorState) {
327 let Some(msg) = state.current_generated() else {
328 return;
329 };
330
331 let width = area.width as usize;
332 let mut lines = Vec::new();
333
334 let emoji = msg.emoji.as_deref().unwrap_or("");
336 let title_width = if emoji.is_empty() {
337 width
338 } else {
339 width.saturating_sub(emoji.chars().count() + 1)
340 };
341 let title = truncate_width(msg.title_without_repeated_emoji(), title_width);
342
343 if emoji.is_empty() {
344 lines.push(Line::from(Span::styled(
345 title,
346 Style::default()
347 .fg(theme::text_primary_color())
348 .add_modifier(Modifier::BOLD),
349 )));
350 } else {
351 lines.push(Line::from(vec![
352 Span::styled(emoji, Style::default()),
353 Span::raw(" "),
354 Span::styled(
355 title,
356 Style::default()
357 .fg(theme::text_primary_color())
358 .add_modifier(Modifier::BOLD),
359 ),
360 ]));
361 }
362
363 lines.push(Line::from(""));
365
366 for body_line in msg.message.lines() {
368 let truncated = truncate_width(body_line, width);
369 lines.push(Line::from(Span::styled(
370 truncated,
371 Style::default().fg(theme::text_primary_color()),
372 )));
373 }
374
375 lines.push(Line::from(""));
377 lines.push(Line::from(vec![
378 Span::styled("e", Style::default().fg(theme::accent_secondary())),
379 Span::styled(" edit ", theme::dimmed()),
380 Span::styled("n/p", Style::default().fg(theme::accent_secondary())),
381 Span::styled(" cycle ", theme::dimmed()),
382 Span::styled("Enter", Style::default().fg(theme::accent_secondary())),
383 Span::styled(" commit", theme::dimmed()),
384 ]));
385
386 let paragraph = Paragraph::new(lines);
387 frame.render_widget(paragraph, area);
388}
389
390#[must_use]
392pub fn render_message_preview(msg: &GeneratedMessage, width: usize) -> Line<'static> {
393 let emoji = msg.emoji.as_deref().unwrap_or("");
394 let title_width = if emoji.is_empty() {
395 width
396 } else {
397 width.saturating_sub(emoji.chars().count() + 1)
398 };
399 let title = truncate_width(msg.title_without_repeated_emoji(), title_width);
400
401 if emoji.is_empty() {
402 Line::from(Span::styled(title, theme::dimmed()))
403 } else {
404 Line::from(vec![
405 Span::raw(emoji.to_string()),
406 Span::raw(" "),
407 Span::styled(title, theme::dimmed()),
408 ])
409 }
410}