git_iris/studio/components/
code_view.rs1use ratatui::Frame;
6use ratatui::layout::Rect;
7use ratatui::style::{Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{
10 Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
11};
12use std::fs;
13use std::path::{Path, PathBuf};
14use unicode_width::UnicodeWidthStr;
15
16use super::syntax::SyntaxHighlighter;
17use crate::studio::theme;
18use crate::studio::utils::expand_tabs;
19
20#[derive(Debug, Clone, Default)]
26pub struct CodeViewState {
27 current_file: Option<PathBuf>,
29 lines: Vec<String>,
31 scroll_offset: usize,
33 selected_line: usize,
35 selection: Option<(usize, usize)>,
37}
38
39impl CodeViewState {
40 pub fn new() -> Self {
42 Self::default()
43 }
44
45 pub fn load_file(&mut self, path: &Path) -> std::io::Result<()> {
47 let content = fs::read_to_string(path)?;
48 self.lines = content.lines().map(String::from).collect();
49 self.current_file = Some(path.to_path_buf());
50 self.scroll_offset = 0;
51 self.selected_line = 1;
52 self.selection = None;
53 Ok(())
54 }
55
56 pub fn current_file(&self) -> Option<&Path> {
58 self.current_file.as_deref()
59 }
60
61 pub fn lines(&self) -> &[String] {
63 &self.lines
64 }
65
66 pub fn line_count(&self) -> usize {
68 self.lines.len()
69 }
70
71 pub fn scroll_offset(&self) -> usize {
73 self.scroll_offset
74 }
75
76 pub fn selected_line(&self) -> usize {
78 self.selected_line
79 }
80
81 pub fn set_selected_line(&mut self, line: usize) {
83 if line > 0 && line <= self.lines.len() {
84 self.selected_line = line;
85 }
86 }
87
88 pub fn selection(&self) -> Option<(usize, usize)> {
90 self.selection
91 }
92
93 pub fn set_selection(&mut self, start: usize, end: usize) {
95 if start > 0 && end >= start && end <= self.lines.len() {
96 self.selection = Some((start, end));
97 }
98 }
99
100 pub fn clear_selection(&mut self) {
102 self.selection = None;
103 }
104
105 pub fn scroll_up(&mut self, amount: usize) {
107 self.scroll_offset = self.scroll_offset.saturating_sub(amount);
108 }
109
110 pub fn scroll_down(&mut self, amount: usize) {
112 let max_offset = self.lines.len().saturating_sub(1);
113 self.scroll_offset = (self.scroll_offset + amount).min(max_offset);
114 }
115
116 pub fn scroll_to_line(&mut self, line: usize, visible_height: usize) {
118 if line == 0 || self.lines.is_empty() {
119 return;
120 }
121 let line_idx = line.saturating_sub(1);
122
123 if line_idx < self.scroll_offset {
125 self.scroll_offset = line_idx;
126 }
127 else if line_idx >= self.scroll_offset + visible_height {
129 self.scroll_offset = line_idx.saturating_sub(visible_height.saturating_sub(1));
130 }
131 }
132
133 pub fn move_up(&mut self, amount: usize, visible_height: usize) {
135 if self.selected_line > 1 {
136 self.selected_line = self.selected_line.saturating_sub(amount).max(1);
137 self.scroll_to_line(self.selected_line, visible_height);
138 }
139 }
140
141 pub fn move_down(&mut self, amount: usize, visible_height: usize) {
143 if self.selected_line < self.lines.len() {
144 self.selected_line = (self.selected_line + amount).min(self.lines.len());
145 self.scroll_to_line(self.selected_line, visible_height);
146 }
147 }
148
149 pub fn goto_first(&mut self) {
151 self.selected_line = 1;
152 self.scroll_offset = 0;
153 }
154
155 pub fn goto_last(&mut self, visible_height: usize) {
157 self.selected_line = self.lines.len().max(1);
158 self.scroll_to_line(self.selected_line, visible_height);
159 }
160
161 pub fn is_loaded(&self) -> bool {
163 self.current_file.is_some()
164 }
165
166 pub fn select_by_row(&mut self, row: usize) -> bool {
169 let target_line = self.scroll_offset + row + 1; if target_line <= self.lines.len() && target_line != self.selected_line {
171 self.selected_line = target_line;
172 true
173 } else {
174 false
175 }
176 }
177}
178
179pub fn render_code_view(
185 frame: &mut Frame,
186 area: Rect,
187 state: &CodeViewState,
188 title: &str,
189 focused: bool,
190) {
191 let block = Block::default()
192 .title(format!(" {} ", title))
193 .borders(Borders::ALL)
194 .border_style(if focused {
195 theme::focused_border()
196 } else {
197 theme::unfocused_border()
198 });
199
200 let inner = block.inner(area);
201 frame.render_widget(block, area);
202
203 if inner.height == 0 || inner.width == 0 {
204 return;
205 }
206
207 if !state.is_loaded() {
209 let placeholder = Paragraph::new("Select a file from the tree")
210 .style(Style::default().fg(theme::text_dim_color()));
211 frame.render_widget(placeholder, inner);
212 return;
213 }
214
215 let visible_height = inner.height as usize;
216 let lines = state.lines();
217 let scroll_offset = state.scroll_offset();
218 let line_num_width = lines.len().to_string().len().max(3);
219
220 let highlighter = state.current_file().map(SyntaxHighlighter::for_path);
222
223 let display_lines: Vec<Line> = lines
224 .iter()
225 .enumerate()
226 .skip(scroll_offset)
227 .take(visible_height)
228 .map(|(idx, content)| {
229 render_code_line(
230 idx + 1, content,
232 line_num_width,
233 inner.width as usize,
234 state.selected_line,
235 state.selection(),
236 highlighter.as_ref(),
237 )
238 })
239 .collect();
240
241 let paragraph = Paragraph::new(display_lines);
242 frame.render_widget(paragraph, inner);
243
244 if lines.len() > visible_height {
246 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
247 .begin_symbol(None)
248 .end_symbol(None);
249
250 let mut scrollbar_state = ScrollbarState::new(lines.len()).position(scroll_offset);
251
252 frame.render_stateful_widget(
253 scrollbar,
254 area.inner(ratatui::layout::Margin {
255 vertical: 1,
256 horizontal: 0,
257 }),
258 &mut scrollbar_state,
259 );
260 }
261}
262
263fn render_code_line(
265 line_num: usize,
266 content: &str,
267 line_num_width: usize,
268 max_width: usize,
269 selected_line: usize,
270 selection: Option<(usize, usize)>,
271 highlighter: Option<&SyntaxHighlighter>,
272) -> Line<'static> {
273 let content = expand_tabs(content, 4);
275
276 let is_selected = line_num == selected_line;
277 let is_in_selection =
278 selection.is_some_and(|(start, end)| line_num >= start && line_num <= end);
279
280 let line_num_style = if is_selected {
282 theme::line_number().add_modifier(Modifier::BOLD)
283 } else {
284 theme::line_number()
285 };
286
287 let indicator = if is_selected { ">" } else { " " };
289 let indicator_style = if is_selected {
290 Style::default()
291 .fg(theme::accent_primary())
292 .add_modifier(Modifier::BOLD)
293 } else {
294 Style::default()
295 };
296
297 let mut spans = vec![
299 Span::styled(indicator.to_string(), indicator_style),
300 Span::styled(
301 format!("{:>width$}", line_num, width = line_num_width),
302 line_num_style,
303 ),
304 Span::styled(" │ ", Style::default().fg(theme::text_muted_color())),
305 ];
306
307 let available_width = max_width.saturating_sub(line_num_width + 4); if let Some(hl) = highlighter {
312 let styled_spans = hl.highlight_line(&content);
313 let mut display_width = 0;
314
315 for (style, text) in styled_spans {
316 if display_width >= available_width {
317 break;
318 }
319
320 let remaining = available_width - display_width;
321 let mut truncated = String::new();
323 let mut width = 0;
324 for c in text.chars() {
325 let c_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(1);
326 if width + c_width > remaining {
327 break;
328 }
329 truncated.push(c);
330 width += c_width;
331 }
332 display_width += width;
333
334 let final_style = if is_in_selection {
336 style.bg(theme::bg_selection_color())
337 } else if is_selected {
338 style
340 } else {
341 style
342 };
343
344 spans.push(Span::styled(truncated, final_style));
345 }
346
347 if content.width() > available_width {
349 spans.push(Span::styled(
350 "...",
351 Style::default().fg(theme::text_muted_color()),
352 ));
353 }
354 } else {
355 let content_style = if is_in_selection {
357 Style::default()
358 .fg(theme::text_primary_color())
359 .bg(theme::bg_selection_color())
360 } else if is_selected {
361 Style::default().fg(theme::text_primary_color())
362 } else {
363 Style::default().fg(theme::text_secondary_color())
364 };
365
366 let content_width = content.width();
367 let display_content = if content_width > available_width {
368 let mut truncated = String::new();
370 let mut width = 0;
371 let max_width = available_width.saturating_sub(3);
372 for c in content.chars() {
373 let c_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(1);
374 if width + c_width > max_width {
375 break;
376 }
377 truncated.push(c);
378 width += c_width;
379 }
380 format!("{}...", truncated)
381 } else {
382 content.clone()
383 };
384
385 spans.push(Span::styled(display_content, content_style));
386 }
387
388 Line::from(spans)
389}