Skip to main content

seal_tui/
lib.rs

1//! seal-tui - GitHub-style code review TUI for seal
2//!
3//! Uses Elm Architecture (Model/Message/Update/View) with ftui rendering.
4//! Replaces the standalone botseal-ui with direct SealServices integration.
5
6#![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
70/// Run the TUI application with direct `SealServices` integration.
71///
72/// # Errors
73///
74/// Returns an error if the terminal cannot be initialized or an I/O error occurs.
75pub 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    // Load theme
80    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    // Initial terminal size
121    let (width, height) = (80, 24);
122
123    // Create model
124    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    // Store repo path for display in header
131    model.repo_path = Some(repo_root.display().to_string());
132
133    // Load initial data
134    model.reviews = client.list_reviews(None).unwrap_or_default();
135
136    // Initialize renderer
137    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    // Main loop
186    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        // Poll for input
209        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    // Handle inline editor submission
436    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
653/// Build file cache entries from data returned by seal.
654fn 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}