Skip to main content

ck_tui/
preview.rs

1use crate::chunks::{
2    ChunkDisplayLine, IndexedChunkMeta, chunk_file_live, collect_chunk_display_lines,
3};
4use crate::colors::*;
5use crate::utils::{
6    apply_heatmap_color_to_token, calculate_token_similarity, find_repo_root, split_into_tokens,
7    syntax_set, theme_set,
8};
9use ck_core::pdf;
10use ck_index::load_index_entry;
11use ratatui::style::{Color, Modifier, Style};
12use ratatui::text::{Line, Span};
13use std::fs;
14use std::path::{Path, PathBuf};
15use syntect::easy::HighlightLines;
16
17pub fn load_preview_lines(
18    path: &Path,
19) -> Result<(Vec<String>, bool, Vec<IndexedChunkMeta>), String> {
20    let resolved_path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
21    let repo_root = find_repo_root(&resolved_path);
22    let is_pdf = pdf::is_pdf_file(&resolved_path);
23
24    let (_content, lines) = if is_pdf {
25        let root = repo_root.clone().ok_or_else(|| {
26            "PDF preview unavailable (missing .ck index). Run `ck --index .` first.".to_string()
27        })?;
28
29        let cache_path = pdf::get_content_cache_path(&root, &resolved_path);
30        let content = fs::read_to_string(&cache_path).map_err(|err| {
31            format!(
32                "PDF preview unavailable ({}). Run `ck --index .` to generate cache.",
33                err
34            )
35        })?;
36        let lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
37        (content, lines)
38    } else {
39        let content = fs::read_to_string(&resolved_path)
40            .map_err(|err| format!("Could not read {}: {}", resolved_path.display(), err))?;
41        let lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
42        (content, lines)
43    };
44
45    // Use live chunking instead of cached index data (same approach as --dump-chunks)
46    let chunk_spans = if is_pdf {
47        // For PDFs, we still need to fall back to cached data since we can't chunk PDF content directly
48        if let Some(root) = repo_root {
49            load_chunk_spans(&root, &resolved_path).unwrap_or_default()
50        } else {
51            Vec::new()
52        }
53    } else {
54        // For regular files, use live chunking with fallback to cached data
55        match chunk_file_live(&resolved_path) {
56            Ok((_, chunks)) => chunks,
57            Err(_) => {
58                // If live chunking fails, fall back to cached data if available
59                if let Some(root) = repo_root {
60                    load_chunk_spans(&root, &resolved_path).unwrap_or_default()
61                } else {
62                    Vec::new()
63                }
64            }
65        }
66    };
67
68    Ok((lines, is_pdf, chunk_spans))
69}
70
71fn load_chunk_spans(repo_root: &Path, file_path: &Path) -> Result<Vec<IndexedChunkMeta>, String> {
72    let standard_path = file_path
73        .strip_prefix(repo_root)
74        .unwrap_or(file_path)
75        .to_path_buf();
76    let index_dir = repo_root.join(".ck");
77    let sidecar_path = index_dir.join(format!("{}.ck", standard_path.display()));
78
79    if !sidecar_path.exists() {
80        return Ok(Vec::new());
81    }
82
83    let entry = load_index_entry(&sidecar_path)
84        .map_err(|err| format!("Failed to load chunk metadata: {}", err))?;
85    let mut metas: Vec<IndexedChunkMeta> = entry
86        .chunks
87        .iter()
88        .map(|chunk| IndexedChunkMeta {
89            span: chunk.span.clone(),
90            chunk_type: chunk.chunk_type.clone(),
91            breadcrumb: chunk.breadcrumb.clone(),
92            ancestry: chunk.ancestry.clone().unwrap_or_default(),
93            estimated_tokens: chunk.estimated_tokens,
94            byte_length: chunk.byte_length,
95            leading_trivia: chunk.leading_trivia.clone(),
96            trailing_trivia: chunk.trailing_trivia.clone(),
97        })
98        .collect();
99
100    let has_non_module = metas
101        .iter()
102        .any(|meta| meta.chunk_type.as_deref() != Some("module"));
103    if has_non_module {
104        metas.retain(|meta| meta.chunk_type.as_deref() != Some("module"));
105    }
106
107    Ok(metas)
108}
109
110#[allow(clippy::too_many_arguments)]
111pub fn render_heatmap_preview(
112    lines: &[String],
113    context_start: usize,
114    context_end: usize,
115    file_path: &Path,
116    score: f32,
117    match_line: usize,
118    query: &str,
119) -> Vec<Line<'static>> {
120    let mut colored_lines = Vec::new();
121
122    // Header line
123    colored_lines.push(Line::from(vec![Span::styled(
124        format!("File: {} | Score: {:.3}\n", file_path.display(), score),
125        Style::default().fg(COLOR_CYAN),
126    )]));
127
128    // Apply heatmap to each line
129    for (idx, line) in lines[context_start..context_end].iter().enumerate() {
130        let line_num = context_start + idx + 1;
131        let is_match_line = line_num == match_line;
132        let in_chunk_range = line_num >= match_line.saturating_sub(5) && line_num <= match_line + 5;
133
134        let mut line_spans = vec![Span::styled(
135            format!("{:4} | ", line_num),
136            if is_match_line {
137                Style::default()
138                    .fg(COLOR_YELLOW)
139                    .add_modifier(Modifier::BOLD)
140            } else if in_chunk_range {
141                Style::default().fg(COLOR_CYAN) // Chunk region in cyan
142            } else {
143                Style::default().fg(COLOR_DARK_GRAY)
144            },
145        )];
146
147        // Apply heatmap coloring
148        let tokens = split_into_tokens(line);
149        for token in tokens {
150            let similarity = calculate_token_similarity(&token, query);
151            let color = apply_heatmap_color_to_token(&token, similarity);
152
153            let style = if color == Color::Reset {
154                Style::default().fg(COLOR_WHITE)
155            } else {
156                Style::default().fg(color)
157            };
158
159            line_spans.push(Span::styled(token.to_string(), style));
160        }
161
162        colored_lines.push(Line::from(line_spans));
163    }
164
165    colored_lines
166}
167
168#[allow(clippy::too_many_arguments)]
169pub fn render_syntax_preview(
170    lines: &[String],
171    context_start: usize,
172    context_end: usize,
173    file_path: &PathBuf,
174    score: f32,
175    match_line: usize,
176) -> Vec<Line<'static>> {
177    let mut colored_lines = Vec::new();
178
179    // Header
180    colored_lines.push(Line::from(vec![Span::styled(
181        format!("File: {} | Score: {:.3}\n", file_path.display(), score),
182        Style::default().fg(COLOR_CYAN),
183    )]));
184
185    // Initialize syntect assets once
186    let ps = syntax_set();
187    let ts = theme_set();
188    let theme = ts
189        .themes
190        .get("base16-ocean.dark")
191        .or_else(|| ts.themes.values().next());
192
193    // Detect syntax from file extension
194    let syntax = ps
195        .find_syntax_for_file(file_path)
196        .ok()
197        .flatten()
198        .unwrap_or_else(|| ps.find_syntax_plain_text());
199
200    let mut highlighter = match theme {
201        Some(theme) => HighlightLines::new(syntax, theme),
202        None => {
203            // Fallback: render without syntax colors
204            for (idx, line) in lines[context_start..context_end].iter().enumerate() {
205                let line_num = context_start + idx + 1;
206                let is_match_line = line_num == match_line;
207                let in_chunk_range =
208                    line_num >= match_line.saturating_sub(5) && line_num <= match_line + 5;
209
210                let line_spans = vec![
211                    Span::styled(
212                        format!("{:4} | ", line_num),
213                        if is_match_line {
214                            Style::default()
215                                .fg(COLOR_YELLOW)
216                                .add_modifier(Modifier::BOLD)
217                        } else if in_chunk_range {
218                            Style::default().fg(COLOR_CYAN)
219                        } else {
220                            Style::default().fg(COLOR_DARK_GRAY)
221                        },
222                    ),
223                    Span::styled(line.to_string(), Style::default().fg(COLOR_WHITE)),
224                ];
225
226                colored_lines.push(Line::from(line_spans));
227            }
228
229            return colored_lines;
230        }
231    };
232
233    // Apply syntax highlighting
234    for (idx, line) in lines[context_start..context_end].iter().enumerate() {
235        let line_num = context_start + idx + 1;
236        let is_match_line = line_num == match_line;
237        let in_chunk_range = line_num >= match_line.saturating_sub(5) && line_num <= match_line + 5;
238
239        let mut line_spans = vec![Span::styled(
240            format!("{:4} | ", line_num),
241            if is_match_line {
242                Style::default()
243                    .fg(COLOR_YELLOW)
244                    .add_modifier(Modifier::BOLD)
245            } else if in_chunk_range {
246                Style::default().fg(COLOR_CYAN) // Chunk region in cyan
247            } else {
248                Style::default().fg(COLOR_DARK_GRAY)
249            },
250        )];
251
252        // Highlight the line
253        if let Ok(ranges) = highlighter.highlight_line(line, ps) {
254            for (style, text) in ranges {
255                let fg = style.foreground;
256                let color = Color::Rgb(fg.r, fg.g, fg.b);
257                line_spans.push(Span::styled(text.to_string(), Style::default().fg(color)));
258            }
259        } else {
260            line_spans.push(Span::raw(line.to_string()));
261        }
262
263        colored_lines.push(Line::from(line_spans));
264    }
265
266    colored_lines
267}
268
269#[allow(clippy::too_many_arguments)]
270pub fn render_chunks_preview(
271    lines: &[String],
272    context_start: usize,
273    context_end: usize,
274    file_path: &Path,
275    score: f32,
276    match_line: usize,
277    chunk_meta: Option<&IndexedChunkMeta>,
278    is_pdf: bool,
279    all_chunks: &[IndexedChunkMeta],
280    full_file_mode: bool,
281    disable_match_highlighting: bool,
282) -> Vec<Line<'static>> {
283    let mut colored_lines = Vec::new();
284
285    let header = if let Some(meta) = chunk_meta {
286        let span = &meta.span;
287        let chunk_kind = meta.chunk_type.as_deref().unwrap_or("chunk");
288        let breadcrumb_display = meta
289            .breadcrumb
290            .as_deref()
291            .filter(|crumb| !crumb.is_empty())
292            .map(|crumb| format!(" • {}", crumb))
293            .unwrap_or_else(|| {
294                if !meta.ancestry.is_empty() {
295                    format!(" • {}", meta.ancestry.join("::"))
296                } else {
297                    String::new()
298                }
299            });
300        let token_display = meta
301            .estimated_tokens
302            .map(|tokens| format!(" • ~{} tokens", tokens))
303            .unwrap_or_default();
304
305        format!(
306            "File: {} • Score: {:.3}\n{}{}{} • L{}-{}\n",
307            file_path.display(),
308            score,
309            chunk_kind,
310            breadcrumb_display,
311            token_display,
312            span.line_start,
313            span.line_end
314        )
315    } else if is_pdf {
316        format!(
317            "File: {} • Score: {:.3}
318PDF chunk (approximate)
319",
320            file_path.display(),
321            score
322        )
323    } else {
324        format!(
325            "File: {} • Score: {:.3}
326",
327            file_path.display(),
328            score
329        )
330    };
331
332    colored_lines.push(Line::from(vec![Span::styled(
333        header,
334        Style::default().fg(COLOR_CYAN),
335    )]));
336
337    colored_lines.extend(build_chunk_lines(
338        lines,
339        context_start,
340        context_end,
341        match_line,
342        chunk_meta,
343        all_chunks,
344        full_file_mode,
345        disable_match_highlighting,
346    ));
347
348    colored_lines
349}
350
351#[allow(clippy::too_many_arguments)]
352pub fn build_chunk_lines(
353    lines: &[String],
354    context_start: usize,
355    context_end: usize,
356    match_line: usize,
357    chunk_meta: Option<&IndexedChunkMeta>,
358    all_chunks: &[IndexedChunkMeta],
359    full_file_mode: bool,
360    disable_match_highlighting: bool,
361) -> Vec<Line<'static>> {
362    // Calculate the width needed for line numbers
363    let max_line_num = lines.len();
364    let line_num_width = max_line_num.to_string().len() + 1; // +1 for spacing
365
366    collect_chunk_display_lines(
367        lines,
368        context_start,
369        context_end,
370        if disable_match_highlighting {
371            0
372        } else {
373            match_line
374        },
375        chunk_meta,
376        all_chunks,
377        full_file_mode,
378    )
379    .into_iter()
380    .map(|row| match row {
381        ChunkDisplayLine::Label { prefix, text } => {
382            let mut spans = Vec::new();
383
384            // Add indentation
385            spans.push(Span::styled(
386                " ".repeat(prefix),
387                Style::default().fg(COLOR_DARK_GRAY),
388            ));
389
390            // Create a bar-like header with borders
391            let bar_start = "┌─ ";
392            let bar_end = " ─┐";
393
394            // Left border
395            spans.push(Span::styled(
396                bar_start,
397                Style::default()
398                    .fg(COLOR_CHUNK_BOUNDARY)
399                    .add_modifier(Modifier::BOLD),
400            ));
401
402            // Content with background-like effect
403            spans.push(Span::styled(
404                text,
405                Style::default()
406                    .fg(COLOR_CHUNK_TEXT)
407                    .bg(COLOR_CHUNK_BOUNDARY)
408                    .add_modifier(Modifier::BOLD),
409            ));
410
411            // Right border
412            spans.push(Span::styled(
413                bar_end,
414                Style::default()
415                    .fg(COLOR_CHUNK_BOUNDARY)
416                    .add_modifier(Modifier::BOLD),
417            ));
418
419            Line::from(spans)
420        }
421        ChunkDisplayLine::Content {
422            columns,
423            line_num,
424            text,
425            is_match_line,
426            in_matched_chunk,
427            has_any_chunk,
428        } => {
429            let mut spans = Vec::new();
430
431            // Always render chunk columns with fixed width
432            if columns.is_empty() {
433                spans.push(Span::styled(" ", Style::default().fg(COLOR_DARK_GRAY)));
434            } else {
435                for column in columns {
436                    let mut style = Style::default().fg(if column.is_match {
437                        COLOR_CHUNK_HIGHLIGHT // Orange for highlighted chunk boundaries
438                    } else {
439                        COLOR_CHUNK_BOUNDARY // Spring green for regular chunk boundaries
440                    });
441                    if column.is_match {
442                        style = style.add_modifier(Modifier::BOLD);
443                    }
444                    spans.push(Span::styled(column.ch.to_string(), style));
445                }
446            }
447
448            spans.push(Span::styled(" ", Style::default().fg(COLOR_DARK_GRAY)));
449
450            // Use fixed-width line number formatting
451            spans.push(Span::styled(
452                format!("{:width$} | ", line_num, width = line_num_width),
453                if is_match_line {
454                    Style::default()
455                        .fg(COLOR_YELLOW)
456                        .add_modifier(Modifier::BOLD)
457                } else if in_matched_chunk {
458                    Style::default()
459                        .fg(COLOR_CHUNK_LINE_NUM) // Gold for highlighted chunk line numbers
460                        .add_modifier(Modifier::BOLD)
461                } else {
462                    Style::default().fg(COLOR_GRAY)
463                },
464            ));
465
466            spans.push(Span::styled(
467                text,
468                if in_matched_chunk {
469                    Style::default()
470                        .fg(COLOR_CHUNK_TEXT) // Bright white for highlighted chunk text
471                        .add_modifier(Modifier::BOLD)
472                } else if has_any_chunk {
473                    Style::default().fg(COLOR_WHITE) // Regular white for chunk text
474                } else {
475                    Style::default().fg(COLOR_DARK_GRAY) // Dim for non-chunk text
476                },
477            ));
478
479            Line::from(spans)
480        }
481        ChunkDisplayLine::Message(message) => Line::from(vec![Span::styled(
482            message,
483            Style::default()
484                .fg(COLOR_CHUNK_BOUNDARY)
485                .add_modifier(Modifier::ITALIC),
486        )]),
487    })
488    .collect()
489}
490
491#[allow(dead_code)]
492pub fn build_chunk_strings(
493    lines: &[String],
494    context_start: usize,
495    context_end: usize,
496    match_line: usize,
497    chunk_meta: Option<&IndexedChunkMeta>,
498    all_chunks: &[IndexedChunkMeta],
499    full_file_mode: bool,
500) -> Vec<String> {
501    // Calculate the width needed for line numbers
502    let max_line_num = lines.len();
503    let line_num_width = max_line_num.to_string().len() + 1; // +1 for spacing
504
505    collect_chunk_display_lines(
506        lines,
507        context_start,
508        context_end,
509        match_line,
510        chunk_meta,
511        all_chunks,
512        full_file_mode,
513    )
514    .into_iter()
515    .map(|row| match row {
516        ChunkDisplayLine::Label { prefix, text } => {
517            format!("{}{}", " ".repeat(prefix), text)
518        }
519        ChunkDisplayLine::Content {
520            columns,
521            line_num,
522            text,
523            is_match_line,
524            ..
525        } => {
526            let mut line_buf = String::new();
527            if columns.is_empty() {
528                line_buf.push(' ');
529            } else {
530                for column in columns {
531                    line_buf.push(column.ch);
532                }
533            }
534            line_buf.push(' ');
535            line_buf.push_str(&format!(
536                "{:width$} | {}",
537                line_num,
538                text,
539                width = line_num_width
540            ));
541            if is_match_line {
542                line_buf.push_str("  <= match");
543            }
544            line_buf
545        }
546        ChunkDisplayLine::Message(message) => message,
547    })
548    .collect()
549}
550
551#[allow(dead_code)]
552pub fn dump_chunk_view_internal(
553    path: &Path,
554    match_line: Option<usize>,
555    full_file_mode: bool,
556) -> Result<Vec<String>, String> {
557    let (lines, is_pdf, chunk_spans) = load_preview_lines(path)?;
558
559    if lines.is_empty() {
560        return Ok(vec![format!("File: {} (empty)", path.display())]);
561    }
562
563    let total_lines = lines.len();
564    let mut line_to_focus = match_line
565        .or_else(|| chunk_spans.first().map(|meta| meta.span.line_start))
566        .unwrap_or(1)
567        .clamp(1, total_lines);
568
569    let chunk_meta = chunk_spans
570        .iter()
571        .filter(|meta| {
572            let span = &meta.span;
573            line_to_focus >= span.line_start && line_to_focus <= span.line_end
574        })
575        .min_by_key(|meta| meta.span.line_end.saturating_sub(meta.span.line_start));
576
577    if chunk_meta.is_none() && chunk_spans.is_empty() {
578        line_to_focus = line_to_focus.clamp(1, total_lines);
579    }
580
581    let mut context_start = if full_file_mode {
582        0
583    } else {
584        line_to_focus
585            .saturating_sub(6)
586            .min(total_lines.saturating_sub(1))
587    };
588    let mut context_end = if full_file_mode {
589        total_lines
590    } else {
591        (line_to_focus + 5).min(total_lines)
592    };
593
594    if !full_file_mode && let Some(meta) = chunk_meta {
595        context_start = meta
596            .span
597            .line_start
598            .saturating_sub(1)
599            .min(total_lines.saturating_sub(1));
600        context_end = meta.span.line_end.min(total_lines);
601    }
602
603    if context_end <= context_start {
604        context_end = (context_start + 1).min(total_lines);
605    }
606
607    let mut output = Vec::new();
608
609    let header_lines: Vec<String> = if let Some(meta) = chunk_meta {
610        let span = &meta.span;
611        let chunk_kind = meta.chunk_type.as_deref().unwrap_or("chunk");
612        let breadcrumb_display = meta
613            .breadcrumb
614            .as_deref()
615            .filter(|crumb| !crumb.is_empty())
616            .map(|crumb| format!(" • {}", crumb))
617            .unwrap_or_else(|| {
618                if !meta.ancestry.is_empty() {
619                    format!(" • {}", meta.ancestry.join("::"))
620                } else {
621                    String::new()
622                }
623            });
624        let token_display = meta
625            .estimated_tokens
626            .map(|tokens| format!(" • ~{} tokens", tokens))
627            .unwrap_or_default();
628
629        vec![
630            format!("File: {}", path.display()),
631            format!(
632                "{}{}{} • L{}-{}",
633                chunk_kind, breadcrumb_display, token_display, span.line_start, span.line_end
634            ),
635            String::new(),
636        ]
637    } else if is_pdf {
638        vec![
639            format!("File: {}", path.display()),
640            "PDF chunk (approximate)".to_string(),
641            String::new(),
642        ]
643    } else {
644        vec![format!("File: {}", path.display()), String::new()]
645    };
646
647    output.extend(header_lines);
648
649    let body = build_chunk_strings(
650        &lines,
651        context_start,
652        context_end,
653        line_to_focus,
654        chunk_meta,
655        &chunk_spans,
656        full_file_mode,
657    );
658
659    output.extend(body);
660
661    Ok(output)
662}