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 tui_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 pub fn new() -> Self {
43 let mut textarea = TextArea::default();
44 textarea.set_cursor_line_style(Style::default().bg(theme::bg_highlight_color()));
45 textarea.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
46
47 Self {
48 textarea,
49 generated_messages: Vec::new(),
50 selected_message: 0,
51 edit_mode: false,
52 original_message: String::new(),
53 }
54 }
55
56 pub fn set_messages(&mut self, messages: Vec<GeneratedMessage>) {
58 self.generated_messages = messages;
59 self.selected_message = 0;
60 let first_msg = self.generated_messages.first().cloned();
61 if let Some(msg) = first_msg {
62 self.load_message(&msg);
63 }
64 }
65
66 pub fn add_messages(&mut self, messages: Vec<GeneratedMessage>) -> usize {
69 let first_new_index = self.generated_messages.len();
70 self.generated_messages.extend(messages);
71 self.selected_message = first_new_index;
72 if let Some(msg) = self.generated_messages.get(first_new_index).cloned() {
73 self.load_message(&msg);
74 }
75 first_new_index
76 }
77
78 fn load_message(&mut self, msg: &GeneratedMessage) {
80 let full_message = format_message(msg);
81 self.original_message.clone_from(&full_message);
82
83 self.textarea = TextArea::from(full_message.lines().map(String::from).collect::<Vec<_>>());
85 self.textarea
86 .set_cursor_line_style(Style::default().bg(theme::bg_highlight_color()));
87 self.textarea
88 .set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
89 }
90
91 pub fn message_count(&self) -> usize {
93 self.generated_messages.len()
94 }
95
96 pub fn selected_index(&self) -> usize {
98 self.selected_message
99 }
100
101 pub fn next_message(&mut self) {
103 if !self.generated_messages.is_empty() {
104 self.selected_message = (self.selected_message + 1) % self.generated_messages.len();
105 if let Some(msg) = self.generated_messages.get(self.selected_message) {
106 self.load_message(&msg.clone());
107 }
108 self.edit_mode = false;
109 }
110 }
111
112 pub fn prev_message(&mut self) {
114 if !self.generated_messages.is_empty() {
115 self.selected_message = if self.selected_message == 0 {
116 self.generated_messages.len() - 1
117 } else {
118 self.selected_message - 1
119 };
120 if let Some(msg) = self.generated_messages.get(self.selected_message) {
121 self.load_message(&msg.clone());
122 }
123 self.edit_mode = false;
124 }
125 }
126
127 pub fn enter_edit_mode(&mut self) {
129 self.edit_mode = true;
130 }
131
132 pub fn exit_edit_mode(&mut self) {
134 self.edit_mode = false;
135 }
136
137 pub fn is_editing(&self) -> bool {
139 self.edit_mode
140 }
141
142 pub fn reset(&mut self) {
144 self.textarea = TextArea::from(
145 self.original_message
146 .lines()
147 .map(String::from)
148 .collect::<Vec<_>>(),
149 );
150 self.textarea
151 .set_cursor_line_style(Style::default().bg(theme::bg_highlight_color()));
152 self.textarea
153 .set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
154 self.edit_mode = false;
155 }
156
157 pub fn clear(&mut self) {
159 self.generated_messages.clear();
160 self.selected_message = 0;
161 self.original_message.clear();
162 self.textarea = TextArea::default();
163 self.textarea
164 .set_cursor_line_style(Style::default().bg(theme::bg_highlight_color()));
165 self.textarea
166 .set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
167 self.edit_mode = false;
168 }
169
170 pub fn get_message(&self) -> String {
172 self.textarea.lines().join("\n")
173 }
174
175 pub fn current_generated(&self) -> Option<&GeneratedMessage> {
177 self.generated_messages.get(self.selected_message)
178 }
179
180 pub fn handle_key(&mut self, key: KeyEvent) -> bool {
182 if !self.edit_mode {
183 return false;
184 }
185
186 if let (KeyCode::Esc, _) = (key.code, key.modifiers) {
188 self.exit_edit_mode();
189 true
190 } else {
191 self.textarea.input(key);
193 true
194 }
195 }
196
197 pub fn is_modified(&self) -> bool {
199 self.get_message() != self.original_message
200 }
201
202 pub fn textarea(&self) -> &TextArea<'static> {
204 &self.textarea
205 }
206}
207
208pub fn format_message(msg: &GeneratedMessage) -> String {
214 let emoji = msg.emoji.as_deref().unwrap_or("");
215 let title = if emoji.is_empty() {
216 msg.title.clone()
217 } else {
218 format!("{} {}", emoji, msg.title)
219 };
220
221 if msg.message.is_empty() {
222 title
223 } else {
224 format!("{}\n\n{}", title, msg.message)
225 }
226}
227
228pub fn render_message_editor(
234 frame: &mut Frame,
235 area: Rect,
236 state: &MessageEditorState,
237 title: &str,
238 focused: bool,
239 generating: bool,
240 status_message: Option<&str>,
241) {
242 let count_indicator = if state.message_count() > 1 {
244 format!(
245 " ({}/{})",
246 state.selected_index() + 1,
247 state.message_count()
248 )
249 } else {
250 String::new()
251 };
252
253 let mode_indicator = if state.is_editing() { " [EDITING]" } else { "" };
254
255 let full_title = format!(" {}{}{} ", title, count_indicator, mode_indicator);
256
257 let block = Block::default()
258 .title(full_title)
259 .borders(Borders::ALL)
260 .border_style(if focused {
261 if state.is_editing() {
262 Style::default().fg(theme::accent_primary())
263 } else {
264 theme::focused_border()
265 }
266 } else {
267 theme::unfocused_border()
268 });
269
270 let inner = block.inner(area);
271 frame.render_widget(block, area);
272
273 if inner.height == 0 || inner.width == 0 {
274 return;
275 }
276
277 if state.message_count() == 0 {
278 let placeholder = if generating {
280 let spinner_frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
282 let frame_idx = (std::time::SystemTime::now()
283 .duration_since(std::time::UNIX_EPOCH)
284 .unwrap_or_default()
285 .as_millis()
286 / 100) as usize
287 % spinner_frames.len();
288 let spinner = spinner_frames[frame_idx];
289
290 let status_text = status_message.unwrap_or("Iris is crafting your commit message");
292
293 Paragraph::new(vec![
294 Line::from(""),
295 Line::from(vec![
296 Span::styled(
297 format!("{} ", spinner),
298 Style::default().fg(theme::accent_primary()),
299 ),
300 Span::styled(
301 status_text,
302 Style::default().fg(theme::text_primary_color()),
303 ),
304 ]),
305 ])
306 } else {
307 Paragraph::new(vec![
308 Line::from(Span::styled("No commit message generated", theme::dimmed())),
309 Line::from(""),
310 Line::from(Span::styled(
311 "Press 'r' to regenerate",
312 Style::default().fg(theme::accent_secondary()),
313 )),
314 ])
315 };
316 frame.render_widget(placeholder, inner);
317 } else if state.is_editing() {
318 frame.render_widget(state.textarea(), inner);
320 } else {
321 render_message_view(frame, inner, state);
323 }
324}
325
326fn render_message_view(frame: &mut Frame, area: Rect, state: &MessageEditorState) {
328 let Some(msg) = state.current_generated() else {
329 return;
330 };
331
332 let width = area.width as usize;
333 let mut lines = Vec::new();
334
335 let emoji = msg.emoji.as_deref().unwrap_or("");
337 let title_width = if emoji.is_empty() {
338 width
339 } else {
340 width.saturating_sub(emoji.chars().count() + 1)
341 };
342 let title = truncate_width(&msg.title, title_width);
343
344 if emoji.is_empty() {
345 lines.push(Line::from(Span::styled(
346 title,
347 Style::default()
348 .fg(theme::text_primary_color())
349 .add_modifier(Modifier::BOLD),
350 )));
351 } else {
352 lines.push(Line::from(vec![
353 Span::styled(emoji, Style::default()),
354 Span::raw(" "),
355 Span::styled(
356 title,
357 Style::default()
358 .fg(theme::text_primary_color())
359 .add_modifier(Modifier::BOLD),
360 ),
361 ]));
362 }
363
364 lines.push(Line::from(""));
366
367 for body_line in msg.message.lines() {
369 let truncated = truncate_width(body_line, width);
370 lines.push(Line::from(Span::styled(
371 truncated,
372 Style::default().fg(theme::text_primary_color()),
373 )));
374 }
375
376 lines.push(Line::from(""));
378 lines.push(Line::from(vec![
379 Span::styled("e", Style::default().fg(theme::accent_secondary())),
380 Span::styled(" edit ", theme::dimmed()),
381 Span::styled("n/p", Style::default().fg(theme::accent_secondary())),
382 Span::styled(" cycle ", theme::dimmed()),
383 Span::styled("Enter", Style::default().fg(theme::accent_secondary())),
384 Span::styled(" commit", theme::dimmed()),
385 ]));
386
387 let paragraph = Paragraph::new(lines);
388 frame.render_widget(paragraph, area);
389}
390
391pub 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, 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}