1#![allow(clippy::cast_possible_truncation)]
7#![allow(clippy::cast_sign_loss)]
8#![allow(clippy::too_many_lines)]
9#![allow(clippy::unnecessary_wraps)]
10#![allow(clippy::needless_pass_by_value)]
11#![allow(clippy::literal_string_with_formatting_args)]
12
13pub mod command;
14pub mod config;
15pub mod core_client;
16pub mod db;
17pub mod diff;
18pub mod input;
19pub mod layout;
20pub mod markdown;
21pub mod message;
22pub mod model;
23pub mod render_backend;
24pub mod stream;
25pub mod syntax;
26pub mod text;
27pub mod theme;
28pub mod update;
29pub mod vcs;
30pub mod view;
31
32pub use core_client::CoreClient;
33pub use db::SealClient;
34pub use message::Message;
35pub use model::{Focus, LayoutMode, Model, Screen};
36pub use syntax::{HighlightSpan, Highlighter};
37pub use theme::Theme;
38pub use update::update;
39pub use view::view;
40
41use std::io::Write;
42use std::path::Path;
43use std::process::Command;
44use std::time::Duration;
45
46use anyhow::{Context, Result};
47
48use crate::config::{load_ui_config, save_ui_config};
49use crate::input::map_event_to_message;
50use crate::model::{CommentRequest, DiffViewMode, EditorRequest};
51use crate::render_backend::{enable_raw_mode, Event, RawModeGuard, Renderer, RendererOptions};
52use crate::render_backend::{event_from_ftui, rgba_to_packed, OptimizedBuffer};
53use crate::render_backend::{
54 Cell as OtCell, CellContent as OtCellContent, TextAttributes as OtTextAttributes,
55};
56use crate::stream::SIDE_BY_SIDE_MIN_WIDTH;
57use crate::theme::{load_built_in_theme, load_theme_from_path};
58
59use seal_core::core::SealServices;
60
61use ftui_core::terminal_session::{SessionOptions as FtuiSessionOptions, TerminalSession};
62use ftui_render::buffer::Buffer as FtuiBuffer;
63use ftui_render::cell::{
64 Cell as FtuiCell, CellAttrs as FtuiCellAttrs, CellContent as FtuiCellContent,
65 StyleFlags as FtuiStyleFlags,
66};
67use ftui_render::diff::BufferDiff as FtuiBufferDiff;
68use ftui_render::presenter::{Presenter as FtuiPresenter, TerminalCapabilities};
69
70pub fn run(repo_root: &Path, services: SealServices) -> Result<()> {
76 let ctx = services.context().clone();
77 let client: Box<dyn SealClient> = Box::new(CoreClient::new(ctx, repo_root));
78
79 let mut config = load_ui_config()?.unwrap_or_default();
81 let theme_override = std::env::var("BOTSEAL_UI_THEME")
82 .or_else(|_| std::env::var("BOTCRIT_UI_THEME"))
83 .ok();
84 let theme_selection = theme_override.clone().or_else(|| config.theme.clone());
85
86 let default_theme =
87 load_built_in_theme("default-dark").unwrap_or_else(|| crate::theme::ThemeLoadResult {
88 theme: Theme::default(),
89 syntax_theme: None,
90 });
91
92 let mut selected_builtin: Option<String> = None;
93 let (theme, _syntax_theme) = if let Some(selection) = theme_selection {
94 if let Some(loaded) = load_built_in_theme(&selection) {
95 selected_builtin = Some(selection);
96 (loaded.theme, loaded.syntax_theme)
97 } else {
98 let path = Path::new(&selection);
99 if path.exists() {
100 let loaded = load_theme_from_path(path)
101 .with_context(|| format!("Failed to load theme: {}", path.display()))?;
102 (loaded.theme, loaded.syntax_theme)
103 } else if theme_override.is_some() {
104 anyhow::bail!("Unknown theme: {selection}");
105 } else {
106 (default_theme.theme, default_theme.syntax_theme)
107 }
108 }
109 } else {
110 (default_theme.theme, default_theme.syntax_theme)
111 };
112
113 if theme_override.is_some() {
114 if let Some(name) = selected_builtin {
115 config.theme = Some(name);
116 save_ui_config(&config)?;
117 }
118 }
119
120 let (width, height) = (80, 24);
122
123 let mut model = Model::new(width, height, config);
125 model.theme = theme;
126 model.highlighter = Highlighter::from_ui_theme(&model.theme);
127
128 apply_default_diff_view(&mut model);
129
130 model.repo_path = Some(repo_root.display().to_string());
132
133 model.reviews = client.list_reviews(None).unwrap_or_default();
135
136 let options = RendererOptions {
138 use_alt_screen: false,
139 hide_cursor: false,
140 enable_mouse: false,
141 query_capabilities: false,
142 };
143 let mut renderer = Renderer::new_with_options(width.into(), height.into(), options)
144 .context("Failed to initialize renderer")?;
145 let mut wrap_guard = Some(AutoWrapGuard::new().context("Failed to disable line wrap")?);
146 let mut cursor_guard = Some(CursorGuard::new().context("Failed to hide cursor")?);
147 renderer.set_background(model.theme.background);
148
149 let mut raw_guard: Option<RawModeGuard> = None;
150 let mut ftui_presenter = FtuiPresenter::new(std::io::stdout(), TerminalCapabilities::detect());
151 let mut ftui_prev = FtuiBuffer::new(width, height);
152 let mut ftui_next = FtuiBuffer::new(width, height);
153 let mut terminal_session = Some(
154 TerminalSession::new(FtuiSessionOptions {
155 alternate_screen: true,
156 mouse_capture: true,
157 bracketed_paste: true,
158 focus_events: true,
159 ..Default::default()
160 })
161 .context("Failed to initialize ftui terminal session")?,
162 );
163 terminal_session
164 .as_ref()
165 .expect("ftui session initialized")
166 .hide_cursor()
167 .context("Failed to hide cursor via ftui terminal session")?;
168 if let Ok((term_width, term_height)) = terminal_session
169 .as_ref()
170 .expect("ftui session initialized")
171 .size()
172 {
173 if term_width != model.width || term_height != model.height {
174 model.resize(term_width, term_height);
175 renderer
176 .resize(term_width.into(), term_height.into())
177 .context("Failed to apply initial ftui terminal size")?;
178 ftui_prev = FtuiBuffer::new(term_width, term_height);
179 ftui_next = FtuiBuffer::new(term_width, term_height);
180 }
181 }
182
183 let repo_path = Some(repo_root.to_path_buf());
184
185 loop {
187 renderer.invalidate();
188 model.needs_redraw = false;
189
190 renderer.clear();
191 view(&model, renderer.buffer());
192 bridge_buffer_to_ftui(renderer.buffer(), &mut ftui_next);
193 let diff = FtuiBufferDiff::compute(&ftui_prev, &ftui_next);
194 ftui_presenter
195 .present(&ftui_next, &diff)
196 .context("Failed to present ftui frame")?;
197 ftui_presenter
198 .hide_cursor()
199 .context("Failed to keep cursor hidden")?;
200 std::mem::swap(&mut ftui_prev, &mut ftui_next);
201
202 if model.should_quit {
203 break;
204 }
205
206 handle_data_loading(&mut model, client.as_ref(), repo_path.as_deref());
207
208 let polled = terminal_session
210 .as_ref()
211 .expect("ftui session available")
212 .poll_event(Duration::from_millis(100))
213 .context("Failed polling ftui terminal events")?;
214 if polled {
215 let ft_event = terminal_session
216 .as_ref()
217 .expect("ftui session available")
218 .read_event()
219 .context("Failed reading ftui terminal event")?;
220 if let Some(ft_event) = ft_event {
221 if let Some(event) = event_from_ftui(ft_event) {
222 let resized_to = if let Event::Resize(resize) = &event {
223 Some((resize.width, resize.height))
224 } else {
225 None
226 };
227 process_event(
228 &event,
229 &mut model,
230 &mut EventContext {
231 renderer: &mut renderer,
232 raw_guard: &mut raw_guard,
233 wrap_guard: &mut wrap_guard,
234 cursor_guard: &mut cursor_guard,
235 client: &Some(client.as_ref()),
236 repo_path: repo_path.as_deref(),
237 options,
238 terminal_session: &mut terminal_session,
239 },
240 )?;
241 if let Some((width, height)) = resized_to {
242 ftui_prev = FtuiBuffer::new(width, height);
243 ftui_next = FtuiBuffer::new(width, height);
244 }
245 }
246 }
247 }
248 }
249
250 Ok(())
251}
252
253fn bridge_buffer_to_ftui(src: &OptimizedBuffer, dst: &mut FtuiBuffer) {
254 let width = src.width().min(u32::from(dst.width())) as u16;
255 let height = src.height().min(u32::from(dst.height())) as u16;
256 dst.clear();
257
258 for y in 0..height {
259 for x in 0..width {
260 if let Some(cell) = src.get(u32::from(x), u32::from(y)) {
261 dst.set_raw(x, y, convert_backend_cell(cell));
262 }
263 }
264 }
265}
266
267fn convert_backend_cell(cell: &OtCell) -> FtuiCell {
268 let mut flags = FtuiStyleFlags::empty();
269 if cell.attributes.contains(OtTextAttributes::BOLD) {
270 flags |= FtuiStyleFlags::BOLD;
271 }
272 if cell.attributes.contains(OtTextAttributes::DIM) {
273 flags |= FtuiStyleFlags::DIM;
274 }
275 if cell.attributes.contains(OtTextAttributes::ITALIC) {
276 flags |= FtuiStyleFlags::ITALIC;
277 }
278 if cell.attributes.contains(OtTextAttributes::UNDERLINE) {
279 flags |= FtuiStyleFlags::UNDERLINE;
280 }
281 if cell.attributes.contains(OtTextAttributes::BLINK) {
282 flags |= FtuiStyleFlags::BLINK;
283 }
284 if cell.attributes.contains(OtTextAttributes::INVERSE) {
285 flags |= FtuiStyleFlags::REVERSE;
286 }
287 if cell.attributes.contains(OtTextAttributes::HIDDEN) {
288 flags |= FtuiStyleFlags::HIDDEN;
289 }
290 if cell.attributes.contains(OtTextAttributes::STRIKETHROUGH) {
291 flags |= FtuiStyleFlags::STRIKETHROUGH;
292 }
293 let attrs = FtuiCellAttrs::new(
294 flags,
295 cell.attributes
296 .link_id()
297 .unwrap_or(FtuiCellAttrs::LINK_ID_NONE),
298 );
299
300 let content = match cell.content {
301 OtCellContent::Char(c) => FtuiCellContent::from_char(c),
302 OtCellContent::Empty => FtuiCellContent::EMPTY,
303 OtCellContent::Continuation => FtuiCellContent::CONTINUATION,
304 OtCellContent::Grapheme(_) => FtuiCellContent::from_char('\u{FFFD}'),
305 };
306
307 FtuiCell {
308 content,
309 fg: rgba_to_packed(cell.fg),
310 bg: rgba_to_packed(cell.bg),
311 attrs,
312 }
313}
314
315struct EventContext<'a> {
316 renderer: &'a mut Renderer,
317 raw_guard: &'a mut Option<RawModeGuard>,
318 wrap_guard: &'a mut Option<AutoWrapGuard>,
319 cursor_guard: &'a mut Option<CursorGuard>,
320 client: &'a Option<&'a dyn SealClient>,
321 repo_path: Option<&'a Path>,
322 options: RendererOptions,
323 terminal_session: &'a mut Option<TerminalSession>,
324}
325
326fn process_event(event: &Event, model: &mut Model, ctx: &mut EventContext<'_>) -> Result<()> {
327 let msg = map_event_to_message(model, event);
328 let resize = if let Message::Resize { width, height } = &msg {
329 Some((*width, *height))
330 } else {
331 None
332 };
333 update(model, msg);
334
335 if let Some((width, height)) = resize {
336 ctx.renderer
337 .resize(width.into(), height.into())
338 .context("Failed to resize renderer")?;
339 model.needs_redraw = true;
340 }
341
342 if let Some(request) = model.pending_editor_request.take() {
343 ctx.terminal_session.take();
344 let (prev_width, prev_height) = ctx.renderer.size();
345 let prev_width = prev_width as u16;
346 let prev_height = prev_height as u16;
347 drop(std::mem::replace(
348 ctx.renderer,
349 Renderer::new_with_options(1, 1, ctx.options).expect("renderer"),
350 ));
351 ctx.raw_guard.take();
352 ctx.wrap_guard.take();
353 ctx.cursor_guard.take();
354
355 let _ = open_file_in_editor(ctx.repo_path, request);
356
357 *ctx.raw_guard = Some(enable_raw_mode().context("Failed to enable raw mode")?);
358 let (width, height) = {
359 let session = TerminalSession::new(FtuiSessionOptions {
360 alternate_screen: true,
361 mouse_capture: true,
362 bracketed_paste: true,
363 focus_events: true,
364 ..Default::default()
365 })
366 .context("Failed to reinitialize ftui terminal session")?;
367 session
368 .hide_cursor()
369 .context("Failed to hide cursor via ftui terminal session")?;
370 let size = session.size().unwrap_or((prev_width, prev_height));
371 *ctx.terminal_session = Some(session);
372 size
373 };
374 *ctx.renderer = Renderer::new_with_options(width.into(), height.into(), ctx.options)
375 .context("Failed to initialize renderer")?;
376 ctx.renderer.set_background(model.theme.background);
377 *ctx.wrap_guard = Some(AutoWrapGuard::new().context("Failed to disable line wrap")?);
378 *ctx.cursor_guard = Some(CursorGuard::new().context("Failed to hide cursor")?);
379 model.resize(width, height);
380 model.needs_redraw = true;
381 ctx.renderer.invalidate();
382 }
383
384 if let Some(request) = model.pending_comment_request.take() {
385 ctx.terminal_session.take();
386 let (prev_width, prev_height) = ctx.renderer.size();
387 let prev_width = prev_width as u16;
388 let prev_height = prev_height as u16;
389 drop(std::mem::replace(
390 ctx.renderer,
391 Renderer::new_with_options(1, 1, ctx.options).expect("renderer"),
392 ));
393 ctx.raw_guard.take();
394 ctx.wrap_guard.take();
395 ctx.cursor_guard.take();
396
397 let comment_result = run_comment_editor(ctx.repo_path, &request);
398
399 if let Ok(Some(body)) = &comment_result {
400 if let Some(client) = ctx.client.as_ref() {
401 let persist_result = persist_comment(*client, ctx.repo_path, &request, body);
402 if persist_result.is_ok() {
403 reload_review_data(model, *client, ctx.repo_path);
404 }
405 }
406 }
407
408 *ctx.raw_guard = Some(enable_raw_mode().context("Failed to enable raw mode")?);
409 let (width, height) = {
410 let session = TerminalSession::new(FtuiSessionOptions {
411 alternate_screen: true,
412 mouse_capture: true,
413 bracketed_paste: true,
414 focus_events: true,
415 ..Default::default()
416 })
417 .context("Failed to reinitialize ftui terminal session")?;
418 session
419 .hide_cursor()
420 .context("Failed to hide cursor via ftui terminal session")?;
421 let size = session.size().unwrap_or((prev_width, prev_height));
422 *ctx.terminal_session = Some(session);
423 size
424 };
425 *ctx.renderer = Renderer::new_with_options(width.into(), height.into(), ctx.options)
426 .context("Failed to initialize renderer")?;
427 ctx.renderer.set_background(model.theme.background);
428 *ctx.wrap_guard = Some(AutoWrapGuard::new().context("Failed to disable line wrap")?);
429 *ctx.cursor_guard = Some(CursorGuard::new().context("Failed to hide cursor")?);
430 model.resize(width, height);
431 model.needs_redraw = true;
432 ctx.renderer.invalidate();
433 }
434
435 if let Some(submission) = model.pending_comment_submission.take() {
437 if let Some(client) = ctx.client.as_ref() {
438 let persist_result = persist_comment(
439 *client,
440 ctx.repo_path,
441 &submission.request,
442 &submission.body,
443 );
444 match persist_result {
445 Ok(()) => reload_review_data(model, *client, ctx.repo_path),
446 Err(e) => {
447 model.flash_message = Some(format!("Comment failed: {e}"));
448 }
449 }
450 }
451 model.needs_redraw = true;
452 }
453
454 Ok(())
455}
456
457struct AutoWrapGuard;
458
459impl AutoWrapGuard {
460 fn new() -> std::io::Result<Self> {
461 let mut out = std::io::stdout();
462 out.write_all(b"\x1b[?7l")?;
463 out.flush()?;
464 Ok(Self)
465 }
466}
467
468impl Drop for AutoWrapGuard {
469 fn drop(&mut self) {
470 let mut out = std::io::stdout();
471 let _ = out.write_all(b"\x1b[?7h");
472 let _ = out.flush();
473 }
474}
475
476struct CursorGuard;
477
478impl CursorGuard {
479 fn new() -> std::io::Result<Self> {
480 let mut out = std::io::stdout();
481 out.write_all(b"\x1b[?25l")?;
482 out.flush()?;
483 Ok(Self)
484 }
485}
486
487impl Drop for CursorGuard {
488 fn drop(&mut self) {
489 let mut out = std::io::stdout();
490 let _ = out.write_all(b"\x1b[?25h");
491 let _ = out.flush();
492 }
493}
494
495fn apply_default_diff_view(model: &mut Model) {
496 if let Some(value) = model.config.default_diff_view.as_deref() {
497 if let Some(mode) = parse_diff_view_mode(value) {
498 model.diff_view_mode = mode;
499 }
500 return;
501 }
502
503 if should_default_side_by_side(model) {
504 model.diff_view_mode = DiffViewMode::SideBySide;
505 }
506}
507
508fn parse_diff_view_mode(value: &str) -> Option<DiffViewMode> {
509 let normalized = value.trim().to_ascii_lowercase();
510 match normalized.as_str() {
511 "unified" | "unify" | "uni" => Some(DiffViewMode::Unified),
512 "side-by-side" | "side_by_side" | "sidebyside" | "sbs" => Some(DiffViewMode::SideBySide),
513 _ => None,
514 }
515}
516
517fn should_default_side_by_side(model: &Model) -> bool {
518 let diff_pane_width = match model.layout_mode {
519 LayoutMode::Full | LayoutMode::Compact => {
520 if model.sidebar_visible {
521 model
522 .width
523 .saturating_sub(model.layout_mode.sidebar_width())
524 } else {
525 model.width
526 }
527 }
528 LayoutMode::Overlay | LayoutMode::Single => model.width,
529 };
530
531 u32::from(diff_pane_width) >= SIDE_BY_SIDE_MIN_WIDTH
532}
533
534fn open_file_in_editor(repo_path: Option<&Path>, request: EditorRequest) -> Result<()> {
535 let Some(repo_root) = repo_path else {
536 return Ok(());
537 };
538
539 let file_path = repo_root.join(&request.file_path);
540 if !file_path.exists() {
541 return Ok(());
542 }
543
544 let editor = std::env::var("EDITOR")
545 .or_else(|_| std::env::var("VISUAL"))
546 .unwrap_or_else(|_| "vi".to_string());
547 let mut cmd = Command::new(editor);
548 if let Some(line) = request.line {
549 cmd.arg(format!("+{line}"));
550 }
551 cmd.arg(file_path);
552 let _ = cmd.status();
553 Ok(())
554}
555
556fn run_comment_editor(
557 _repo_path: Option<&Path>,
558 request: &CommentRequest,
559) -> Result<Option<String>> {
560 use std::io::Read;
561
562 let dir = std::env::temp_dir();
563 let tmp_path = dir.join(format!("seal-comment-{}.md", std::process::id()));
564
565 {
566 let mut f =
567 std::fs::File::create(&tmp_path).context("Failed to create temp file for comment")?;
568
569 writeln!(f, "# File: {}", request.file_path)?;
570 let line_range = match request.end_line {
571 Some(end) if end != request.start_line => format!("{}-{}", request.start_line, end),
572 _ => request.start_line.to_string(),
573 };
574 writeln!(f, "# Lines: {line_range}")?;
575 if let Some(thread_id) = &request.thread_id {
576 writeln!(f, "# Thread: {thread_id}")?;
577 }
578 if !request.existing_comments.is_empty() {
579 writeln!(f, "#")?;
580 writeln!(f, "# Existing comments:")?;
581 for c in &request.existing_comments {
582 writeln!(f, "# {}: {}", c.author, c.body)?;
583 }
584 }
585 writeln!(f, "#")?;
586 writeln!(
587 f,
588 "# Write your comment below. Lines starting with # are ignored."
589 )?;
590 writeln!(f, "# Save and exit to submit. Leave empty to cancel.")?;
591 writeln!(f)?;
592 f.flush()?;
593 }
594
595 let editor = std::env::var("EDITOR")
596 .or_else(|_| std::env::var("VISUAL"))
597 .unwrap_or_else(|_| "vi".to_string());
598
599 let status = Command::new(&editor).arg(&tmp_path).status();
600
601 let body = if let Ok(exit) = status {
602 if exit.success() {
603 let mut content = String::new();
604 std::fs::File::open(&tmp_path)
605 .and_then(|mut f| f.read_to_string(&mut content))
606 .context("Failed to read temp file after editor")?;
607
608 let body: String = content
609 .lines()
610 .filter(|line| !line.starts_with('#'))
611 .collect::<Vec<_>>()
612 .join("\n")
613 .trim()
614 .to_string();
615
616 if body.is_empty() {
617 None
618 } else {
619 Some(body)
620 }
621 } else {
622 None
623 }
624 } else {
625 None
626 };
627
628 let _ = std::fs::remove_file(&tmp_path);
629
630 Ok(body)
631}
632
633fn persist_comment(
634 client: &dyn SealClient,
635 _repo_path: Option<&Path>,
636 request: &CommentRequest,
637 body: &str,
638) -> Result<()> {
639 if let Some(thread_id) = &request.thread_id {
640 client.reply(thread_id, body)?;
641 } else {
642 client.comment(
643 &request.review_id,
644 &request.file_path,
645 request.start_line,
646 request.end_line,
647 body,
648 )?;
649 }
650 Ok(())
651}
652
653fn populate_file_cache(model: &mut Model, files: Vec<crate::db::FileData>) {
655 model.file_cache.clear();
656
657 for file_data in files.into_iter().filter(|f| !f.path.starts_with(".seal/")) {
658 let diff = file_data
659 .diff
660 .as_deref()
661 .map(crate::diff::ParsedDiff::parse);
662
663 let file_content = file_data.content.map(|c| crate::model::FileContent {
664 lines: c.lines,
665 start_line: c.start_line,
666 });
667
668 let (highlighted_lines, file_highlighted_lines) = compute_cache_highlights(
669 diff.as_ref(),
670 file_content.as_ref(),
671 &file_data.path,
672 &model.highlighter,
673 );
674
675 model.file_cache.insert(
676 file_data.path,
677 crate::model::FileCacheEntry {
678 diff,
679 file_content,
680 highlighted_lines,
681 file_highlighted_lines,
682 },
683 );
684 }
685
686 model.sync_active_file_cache();
687}
688
689pub(crate) fn rehighlight_file_cache(
690 file_cache: &mut std::collections::HashMap<String, crate::model::FileCacheEntry>,
691 highlighter: &Highlighter,
692) {
693 for (path, entry) in file_cache.iter_mut() {
694 let (highlighted_lines, file_highlighted_lines) = compute_cache_highlights(
695 entry.diff.as_ref(),
696 entry.file_content.as_ref(),
697 path,
698 highlighter,
699 );
700 entry.highlighted_lines = highlighted_lines;
701 entry.file_highlighted_lines = file_highlighted_lines;
702 }
703}
704
705fn reload_review_data(model: &mut Model, client: &dyn SealClient, _repo_path: Option<&Path>) {
706 let Some(review) = &model.current_review else {
707 return;
708 };
709 let review_id = review.review_id.clone();
710 if let Ok(Some(data)) = client.load_review_data(&review_id) {
711 model.current_review = Some(data.detail);
712 model.threads = data.threads;
713 model.all_comments = data.comments;
714 populate_file_cache(model, data.files);
715 }
716}
717
718fn handle_data_loading(model: &mut Model, client: &dyn SealClient, _repo_path: Option<&Path>) {
719 if model.screen == Screen::ReviewDetail && model.current_review.is_none() {
720 let reviews = model.filtered_reviews();
721 if let Some(review) = reviews.get(model.list_index) {
722 let review_id = review.review_id.clone();
723 if let Ok(Some(data)) = client.load_review_data(&review_id) {
724 model.current_review = Some(data.detail);
725 model.threads = data.threads;
726 model.all_comments = data.comments;
727 populate_file_cache(model, data.files);
728 }
729 }
730 }
731
732 if model.screen == Screen::ReviewDetail && model.current_review.is_some() {
733 model.sync_active_file_cache();
734 }
735
736 ensure_default_expanded_thread(model);
737}
738
739fn ensure_default_expanded_thread(model: &mut Model) {
740 if model.expanded_thread.is_some() {
741 return;
742 }
743
744 if let Some(thread) = model.threads_for_current_file().first() {
745 model.expanded_thread = Some(thread.thread_id.clone());
746 return;
747 }
748
749 if let Some(thread) = model.threads.first() {
750 model.expanded_thread = Some(thread.thread_id.clone());
751 }
752}
753
754fn compute_diff_highlights(
755 diff: &crate::diff::ParsedDiff,
756 file_path: &str,
757 highlighter: &Highlighter,
758) -> Vec<Vec<HighlightSpan>> {
759 let mut result = Vec::new();
760
761 let Some(mut file_hl) = highlighter.for_file(file_path) else {
762 return result;
763 };
764
765 for hunk in &diff.hunks {
766 result.push(Vec::new());
767
768 for line in &hunk.lines {
769 let spans = file_hl.highlight_line(&line.content);
770 result.push(spans);
771 }
772 }
773
774 result
775}
776
777fn compute_file_highlights(
778 lines: &[String],
779 file_path: &str,
780 highlighter: &Highlighter,
781) -> Vec<Vec<HighlightSpan>> {
782 let Some(mut file_hl) = highlighter.for_file(file_path) else {
783 return Vec::new();
784 };
785
786 lines
787 .iter()
788 .map(|line| file_hl.highlight_line(line))
789 .collect()
790}
791
792fn compute_cache_highlights(
793 diff: Option<&crate::diff::ParsedDiff>,
794 file_content: Option<&crate::model::FileContent>,
795 file_path: &str,
796 highlighter: &Highlighter,
797) -> (Vec<Vec<HighlightSpan>>, Vec<Vec<HighlightSpan>>) {
798 let highlighted_lines = if let Some(parsed) = diff {
799 compute_diff_highlights(parsed, file_path, highlighter)
800 } else if let Some(content) = file_content {
801 compute_file_highlights(&content.lines, file_path, highlighter)
802 } else {
803 Vec::new()
804 };
805
806 let file_highlighted_lines = if diff.is_some() {
807 if let Some(content) = file_content {
808 compute_file_highlights(&content.lines, file_path, highlighter)
809 } else {
810 Vec::new()
811 }
812 } else {
813 Vec::new()
814 };
815
816 (highlighted_lines, file_highlighted_lines)
817}